From c371ad2dd447a3487a470461e85382f3b75db3fb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 22 Nov 2024 10:29:37 +0100 Subject: [PATCH] [StaticWebAssets] Improve globbing match performance (#44159) * Removes the usage of Microsoft.Extensions.FileSystemGlobbing in favor of a custom implementation optimized for our scenarios. * Improves the parallelism in DefineStaticWebAssetEndpoints. * Spanifies the string manipulation logic. --- .../Compression/ResolveCompressedAssets.cs | 15 +- .../ComputeReferenceStaticWebAssetItems.cs | 2 +- ...ComputeStaticWebAssetsForCurrentProject.cs | 4 +- .../Tasks/Data/ContentTypeMapping.cs | 13 - .../Tasks/Data/ContentTypeProvider.cs | 865 ++++++++++-------- .../Tasks/Data/StaticWebAsset.cs | 2 +- .../Tasks/Data/StaticWebAssetPathPattern.cs | 217 +++-- .../Tasks/Data/StaticWebAssetPathSegment.cs | 13 +- .../Tasks/Data/StaticWebAssetSegmentPart.cs | 32 +- .../Tasks/DefineStaticWebAssetEndpoints.cs | 342 ++++--- .../Tasks/DefineStaticWebAssets.cs | 206 ++--- .../Tasks/FilterStaticWebAssetEndpoints.cs | 2 +- .../Tasks/FingerprintPatternMatcher.cs | 177 ++++ ...erateStaticWebAssetsDevelopmentManifest.cs | 2 +- .../Tasks/GenerateStaticWebAssetsPropsFile.cs | 2 +- .../GenerateStaticWebAssetsPropsFile50.cs | 2 +- .../ValidateStaticWebAssetsUniquePaths.cs | 4 +- ...osoft.NET.Sdk.StaticWebAssets.Tasks.csproj | 46 +- ...printedStaticWebAssetEndpointsForAssets.cs | 2 +- .../Tasks/ScopedCss/ComputeCssScope.cs | 2 +- .../Tasks/ScopedCss/ConcatenateCssFiles.cs | 6 +- .../Tasks/ScopedCss/ConcatenateCssFiles50.cs | 10 +- .../Tasks/ScopedCss/RewriteCss.cs | 2 +- .../GenerateServiceWorkerAssetsManifest.cs | 4 +- .../Tasks/UpdateStaticWebAssetEndpoints.cs | 24 +- .../Tasks/Utils/Globbing/GlobMatch.cs | 13 + .../Tasks/Utils/Globbing/GlobNode.cs | 113 +++ .../Tasks/Utils/Globbing/PathTokenizer.cs | 118 +++ .../Globbing/StaticWebAssetGlobMatcher.cs | 551 +++++++++++ .../StaticWebAssetGlobMatcherBuilder.cs | 227 +++++ src/StaticWebAssetsSdk/Tasks/Utils/OSPath.cs | 2 + .../ContentTypeProviderTests.cs | 155 ++++ .../DefineStaticWebAssetEndpointsTest.cs | 108 +++ .../FingerprintPatternMatcherTest.cs | 128 +++ ...rateStaticWebAssetEndpointsManifestTest.cs | 21 +- .../Globbing/PathTokenizerTest.cs | 133 +++ ...icWebAssetGlobMatcherTest.Compatibility.cs | 302 ++++++ .../Globbing/StaticWebAssetGlobMatcherTest.cs | 388 ++++++++ .../StaticWebAssetPathPatternTest.cs | 145 +-- 39 files changed, 3418 insertions(+), 982 deletions(-) create mode 100644 src/StaticWebAssetsSdk/Tasks/FingerprintPatternMatcher.cs create mode 100644 src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobMatch.cs create mode 100644 src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobNode.cs create mode 100644 src/StaticWebAssetsSdk/Tasks/Utils/Globbing/PathTokenizer.cs create mode 100644 src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcher.cs create mode 100644 src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcherBuilder.cs create mode 100644 test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ContentTypeProviderTests.cs create mode 100644 test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/FingerprintPatternMatcherTest.cs create mode 100644 test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/PathTokenizerTest.cs create mode 100644 test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.Compatibility.cs create mode 100644 test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.cs diff --git a/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs b/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs index 46bf28ad9411..608e0eb3b645 100644 --- a/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/Compression/ResolveCompressedAssets.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; using Microsoft.Build.Framework; -using Microsoft.Extensions.FileSystemGlobbing; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -60,12 +59,15 @@ public override bool Execute() var includePatterns = SplitPattern(IncludePatterns); var excludePatterns = SplitPattern(ExcludePatterns); - var matcher = new Matcher(); - matcher.AddIncludePatterns(includePatterns); - matcher.AddExcludePatterns(excludePatterns); + var matcher = new StaticWebAssetGlobMatcherBuilder() + .AddIncludePatterns(includePatterns) + .AddExcludePatterns(excludePatterns) + .Build(); var matchingCandidateAssets = new List(); + var matchContext = StaticWebAssetGlobMatcher.CreateMatchContext(); + // Add each candidate asset to each compression configuration with a matching pattern. foreach (var asset in candidates) { @@ -80,9 +82,10 @@ public override bool Execute() } var relativePath = asset.ComputePathWithoutTokens(asset.RelativePath); - var match = matcher.Match(relativePath); + matchContext.SetPathAndReinitialize(relativePath.AsSpan()); + var match = matcher.Match(matchContext); - if (!match.HasMatches) + if (!match.IsMatch) { Log.LogMessage( MessageImportance.Low, diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs b/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs index 59a4bb586e63..6243922443bd 100644 --- a/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs +++ b/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs @@ -44,7 +44,7 @@ public override bool Execute() var resultAssets = new List(); foreach (var (key, group) in existingAssets) { - if (!ComputeReferenceStaticWebAssetItems.TryGetUniqueAsset(group, out var selected)) + if (!TryGetUniqueAsset(group, out var selected)) { if (selected == null) { diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs b/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs index c9e96261c94f..16f4de166d86 100644 --- a/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs +++ b/src/StaticWebAssetsSdk/Tasks/ComputeStaticWebAssetsForCurrentProject.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Framework; @@ -36,7 +36,7 @@ public override bool Execute() var resultAssets = new List(); foreach (var (key, group) in currentProjectAssets) { - if (!ComputeStaticWebAssetsForCurrentProject.TryGetUniqueAsset(group, out var selected)) + if (!TryGetUniqueAsset(group, out var selected)) { if (selected == null) { diff --git a/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeMapping.cs b/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeMapping.cs index 1ca2e1e4dbf7..a07f0055f32e 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeMapping.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeMapping.cs @@ -4,15 +4,12 @@ using System.Diagnostics; using System.Globalization; using Microsoft.Build.Framework; -using Microsoft.Extensions.FileSystemGlobbing; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] internal struct ContentTypeMapping(string mimeType, string cache, string pattern, int priority) { - private Matcher _matcher; - public string Pattern { get; set; } = pattern; public string MimeType { get; set; } = mimeType; @@ -27,15 +24,5 @@ internal struct ContentTypeMapping(string mimeType, string cache, string pattern contentTypeMappings.GetMetadata(nameof(Pattern)), int.Parse(contentTypeMappings.GetMetadata(nameof(Priority)), CultureInfo.InvariantCulture)); - internal bool Matches(string identity) - { - if (_matcher == null) - { - _matcher = new Matcher(); - _matcher.AddInclude(Pattern); - } - return _matcher.Match(identity).HasMatches; - } - private readonly string GetDebuggerDisplay() => $"Pattern: {Pattern}, MimeType: {MimeType}, Cache: {Cache}, Priority: {Priority}"; } diff --git a/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeProvider.cs b/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeProvider.cs index b19dd2bc87a3..4bdc069da6ee 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeProvider.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/ContentTypeProvider.cs @@ -6,432 +6,493 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; -internal sealed class ContentTypeProvider(ContentTypeMapping[] customMappings) +internal sealed class ContentTypeProvider { private static readonly Dictionary _builtInMappings = new() { - [".js"] = new ContentTypeMapping("text/javascript", null, "*.js", 1), - [".css"] = new ContentTypeMapping("text/css", null, "*.css", 1), - [".html"] = new ContentTypeMapping("text/html", null, "*.html", 1), - [".json"] = new ContentTypeMapping("application/json", null, "*.json", 1), - [".mjs"] = new ContentTypeMapping("text/javascript", null, "*.mjs", 1), - [".xml"] = new ContentTypeMapping("text/xml", null, "*.xml", 1), - [".htm"] = new ContentTypeMapping("text/html", null, "*.htm", 1), - [".wasm"] = new ContentTypeMapping("application/wasm", null, "*.wasm", 1), - [".txt"] = new ContentTypeMapping("text/plain", null, "*.txt", 1), - [".dll"] = new ContentTypeMapping("application/octet-stream", null, "*.dll", 1), - [".pdb"] = new ContentTypeMapping("application/octet-stream", null, "*.pdb", 1), - [".dat"] = new ContentTypeMapping("application/octet-stream", null, "*.dat", 1), - [".webmanifest"] = new ContentTypeMapping("application/manifest+json", null, "*.webmanifest", 1), - [".jsx"] = new ContentTypeMapping("text/jscript", null, "*.jsx", 1), - [".markdown"] = new ContentTypeMapping("text/markdown", null, "*.markdown", 1), - [".gz"] = new ContentTypeMapping("application/x-gzip", null, "*.gz", 1), - [".md"] = new ContentTypeMapping("text/markdown", null, "*.md", 1), - [".bmp"] = new ContentTypeMapping("image/bmp", null, "*.bmp", 1), - [".jpeg"] = new ContentTypeMapping("image/jpeg", null, "*.jpeg", 1), - [".jpg"] = new ContentTypeMapping("image/jpeg", null, "*.jpg", 1), - [".gif"] = new ContentTypeMapping("image/gif", null, "*.gif", 1), - [".svg"] = new ContentTypeMapping("image/svg+xml", null, "*.svg", 1), - [".png"] = new ContentTypeMapping("image/png", null, "*.png", 1), - [".webp"] = new ContentTypeMapping("image/webp", null, "*.webp", 1), - [".otf"] = new ContentTypeMapping("font/otf", null, "*.otf", 1), - [".woff2"] = new ContentTypeMapping("font/woff2", null, "*.woff2", 1), - [".m4v"] = new ContentTypeMapping("video/mp4", null, "*.m4v", 1), - [".mov"] = new ContentTypeMapping("video/quicktime", null, "*.mov", 1), - [".movie"] = new ContentTypeMapping("video/x-sgi-movie", null, "*.movie", 1), - [".mp2"] = new ContentTypeMapping("video/mpeg", null, "*.mp2", 1), - [".mp4"] = new ContentTypeMapping("video/mp4", null, "*.mp4", 1), - [".mp4v"] = new ContentTypeMapping("video/mp4", null, "*.mp4v", 1), - [".mpa"] = new ContentTypeMapping("video/mpeg", null, "*.mpa", 1), - [".mpe"] = new ContentTypeMapping("video/mpeg", null, "*.mpe", 1), - [".mpeg"] = new ContentTypeMapping("video/mpeg", null, "*.mpeg", 1), - [".mpg"] = new ContentTypeMapping("video/mpeg", null, "*.mpg", 1), - [".mpv2"] = new ContentTypeMapping("video/mpeg", null, "*.mpv2", 1), - [".nsc"] = new ContentTypeMapping("video/x-ms-asf", null, "*.nsc", 1), - [".ogg"] = new ContentTypeMapping("video/ogg", null, "*.ogg", 1), - [".ogv"] = new ContentTypeMapping("video/ogg", null, "*.ogv", 1), - [".webm"] = new ContentTypeMapping("video/webm", null, "*.webm", 1), - [".323"] = new ContentTypeMapping("text/h323", null, "*.323", 1), - [".appcache"] = new ContentTypeMapping("text/cache-manifest", null, "*.appcache", 1), - [".asm"] = new ContentTypeMapping("text/plain", null, "*.asm", 1), - [".bas"] = new ContentTypeMapping("text/plain", null, "*.bas", 1), - [".c"] = new ContentTypeMapping("text/plain", null, "*.c", 1), - [".cnf"] = new ContentTypeMapping("text/plain", null, "*.cnf", 1), - [".cpp"] = new ContentTypeMapping("text/plain", null, "*.cpp", 1), - [".csv"] = new ContentTypeMapping("text/csv", null, "*.csv", 1), - [".disco"] = new ContentTypeMapping("text/xml", null, "*.disco", 1), - [".dlm"] = new ContentTypeMapping("text/dlm", null, "*.dlm", 1), - [".dtd"] = new ContentTypeMapping("text/xml", null, "*.dtd", 1), - [".etx"] = new ContentTypeMapping("text/x-setext", null, "*.etx", 1), - [".h"] = new ContentTypeMapping("text/plain", null, "*.h", 1), - [".hdml"] = new ContentTypeMapping("text/x-hdml", null, "*.hdml", 1), - [".htc"] = new ContentTypeMapping("text/x-component", null, "*.htc", 1), - [".htt"] = new ContentTypeMapping("text/webviewhtml", null, "*.htt", 1), - [".hxt"] = new ContentTypeMapping("text/html", null, "*.hxt", 1), - [".ical"] = new ContentTypeMapping("text/calendar", null, "*.ical", 1), - [".icalendar"] = new ContentTypeMapping("text/calendar", null, "*.icalendar", 1), - [".ics"] = new ContentTypeMapping("text/calendar", null, "*.ics", 1), - [".ifb"] = new ContentTypeMapping("text/calendar", null, "*.ifb", 1), - [".map"] = new ContentTypeMapping("text/plain", null, "*.map", 1), - [".mno"] = new ContentTypeMapping("text/xml", null, "*.mno", 1), - [".odc"] = new ContentTypeMapping("text/x-ms-odc", null, "*.odc", 1), - [".rtx"] = new ContentTypeMapping("text/richtext", null, "*.rtx", 1), - [".sct"] = new ContentTypeMapping("text/scriptlet", null, "*.sct", 1), - [".sgml"] = new ContentTypeMapping("text/sgml", null, "*.sgml", 1), - [".tsv"] = new ContentTypeMapping("text/tab-separated-values", null, "*.tsv", 1), - [".uls"] = new ContentTypeMapping("text/iuls", null, "*.uls", 1), - [".vbs"] = new ContentTypeMapping("text/vbscript", null, "*.vbs", 1), - [".vcf"] = new ContentTypeMapping("text/x-vcard", null, "*.vcf", 1), - [".vcs"] = new ContentTypeMapping("text/plain", null, "*.vcs", 1), - [".vml"] = new ContentTypeMapping("text/xml", null, "*.vml", 1), - [".wml"] = new ContentTypeMapping("text/vnd.wap.wml", null, "*.wml", 1), - [".wmls"] = new ContentTypeMapping("text/vnd.wap.wmlscript", null, "*.wmls", 1), - [".wsdl"] = new ContentTypeMapping("text/xml", null, "*.wsdl", 1), - [".xdr"] = new ContentTypeMapping("text/plain", null, "*.xdr", 1), - [".xsd"] = new ContentTypeMapping("text/xml", null, "*.xsd", 1), - [".xsf"] = new ContentTypeMapping("text/xml", null, "*.xsf", 1), - [".xsl"] = new ContentTypeMapping("text/xml", null, "*.xsl", 1), - [".xslt"] = new ContentTypeMapping("text/xml", null, "*.xslt", 1), - [".woff"] = new ContentTypeMapping("application/font-woff", null, "*.woff", 1), - [".art"] = new ContentTypeMapping("image/x-jg", null, "*.art", 1), - [".cmx"] = new ContentTypeMapping("image/x-cmx", null, "*.cmx", 1), - [".cod"] = new ContentTypeMapping("image/cis-cod", null, "*.cod", 1), - [".dib"] = new ContentTypeMapping("image/bmp", null, "*.dib", 1), - [".ico"] = new ContentTypeMapping("image/x-icon", null, "*.ico", 1), - [".ief"] = new ContentTypeMapping("image/ief", null, "*.ief", 1), - [".jfif"] = new ContentTypeMapping("image/pjpeg", null, "*.jfif", 1), - [".jpe"] = new ContentTypeMapping("image/jpeg", null, "*.jpe", 1), - [".pbm"] = new ContentTypeMapping("image/x-portable-bitmap", null, "*.pbm", 1), - [".pgm"] = new ContentTypeMapping("image/x-portable-graymap", null, "*.pgm", 1), - [".pnm"] = new ContentTypeMapping("image/x-portable-anymap", null, "*.pnm", 1), - [".pnz"] = new ContentTypeMapping("image/png", null, "*.pnz", 1), - [".ppm"] = new ContentTypeMapping("image/x-portable-pixmap", null, "*.ppm", 1), - [".ras"] = new ContentTypeMapping("image/x-cmu-raster", null, "*.ras", 1), - [".rf"] = new ContentTypeMapping("image/vnd.rn-realflash", null, "*.rf", 1), - [".rgb"] = new ContentTypeMapping("image/x-rgb", null, "*.rgb", 1), - [".svgz"] = new ContentTypeMapping("image/svg+xml", null, "*.svgz", 1), - [".tif"] = new ContentTypeMapping("image/tiff", null, "*.tif", 1), - [".tiff"] = new ContentTypeMapping("image/tiff", null, "*.tiff", 1), - [".wbmp"] = new ContentTypeMapping("image/vnd.wap.wbmp", null, "*.wbmp", 1), - [".xbm"] = new ContentTypeMapping("image/x-xbitmap", null, "*.xbm", 1), - [".xpm"] = new ContentTypeMapping("image/x-xpixmap", null, "*.xpm", 1), - [".xwd"] = new ContentTypeMapping("image/x-xwindowdump", null, "*.xwd", 1), - [".3g2"] = new ContentTypeMapping("video/3gpp2", null, "*.3g2", 1), - [".3gp2"] = new ContentTypeMapping("video/3gpp2", null, "*.3gp2", 1), - [".3gp"] = new ContentTypeMapping("video/3gpp", null, "*.3gp", 1), - [".3gpp"] = new ContentTypeMapping("video/3gpp", null, "*.3gpp", 1), - [".asf"] = new ContentTypeMapping("video/x-ms-asf", null, "*.asf", 1), - [".asr"] = new ContentTypeMapping("video/x-ms-asf", null, "*.asr", 1), - [".asx"] = new ContentTypeMapping("video/x-ms-asf", null, "*.asx", 1), - [".avi"] = new ContentTypeMapping("video/x-msvideo", null, "*.avi", 1), - [".dvr"] = new ContentTypeMapping("video/x-ms-dvr", null, "*.dvr", 1), - [".flv"] = new ContentTypeMapping("video/x-flv", null, "*.flv", 1), - [".IVF"] = new ContentTypeMapping("video/x-ivf", null, "*.IVF", 1), - [".lsf"] = new ContentTypeMapping("video/x-la-asf", null, "*.lsf", 1), - [".lsx"] = new ContentTypeMapping("video/x-la-asf", null, "*.lsx", 1), - [".m1v"] = new ContentTypeMapping("video/mpeg", null, "*.m1v", 1), - [".m2ts"] = new ContentTypeMapping("video/vnd.dlna.mpeg-tts", null, "*.m2ts", 1), - [".qt"] = new ContentTypeMapping("video/quicktime", null, "*.qt", 1), - [".ts"] = new ContentTypeMapping("video/vnd.dlna.mpeg-tts", null, "*.ts", 1), - [".tts"] = new ContentTypeMapping("video/vnd.dlna.mpeg-tts", null, "*.tts", 1), - [".wm"] = new ContentTypeMapping("video/x-ms-wm", null, "*.wm", 1), - [".wmp"] = new ContentTypeMapping("video/x-ms-wmp", null, "*.wmp", 1), - [".wmv"] = new ContentTypeMapping("video/x-ms-wmv", null, "*.wmv", 1), - [".wmx"] = new ContentTypeMapping("video/x-ms-wmx", null, "*.wmx", 1), - [".wtv"] = new ContentTypeMapping("video/x-ms-wtv", null, "*.wtv", 1), - [".wvx"] = new ContentTypeMapping("video/x-ms-wvx", null, "*.wvx", 1), - [".aac"] = new ContentTypeMapping("audio/aac", null, "*.aac", 1), - [".adt"] = new ContentTypeMapping("audio/vnd.dlna.adts", null, "*.adt", 1), - [".adts"] = new ContentTypeMapping("audio/vnd.dlna.adts", null, "*.adts", 1), - [".aif"] = new ContentTypeMapping("audio/x-aiff", null, "*.aif", 1), - [".aifc"] = new ContentTypeMapping("audio/aiff", null, "*.aifc", 1), - [".aiff"] = new ContentTypeMapping("audio/aiff", null, "*.aiff", 1), - [".au"] = new ContentTypeMapping("audio/basic", null, "*.au", 1), - [".m3u"] = new ContentTypeMapping("audio/x-mpegurl", null, "*.m3u", 1), - [".m4a"] = new ContentTypeMapping("audio/mp4", null, "*.m4a", 1), - [".mid"] = new ContentTypeMapping("audio/mid", null, "*.mid", 1), - [".midi"] = new ContentTypeMapping("audio/mid", null, "*.midi", 1), - [".mp3"] = new ContentTypeMapping("audio/mpeg", null, "*.mp3", 1), - [".oga"] = new ContentTypeMapping("audio/ogg", null, "*.oga", 1), - [".ra"] = new ContentTypeMapping("audio/x-pn-realaudio", null, "*.ra", 1), - [".ram"] = new ContentTypeMapping("audio/x-pn-realaudio", null, "*.ram", 1), - [".rmi"] = new ContentTypeMapping("audio/mid", null, "*.rmi", 1), - [".rpm"] = new ContentTypeMapping("audio/x-pn-realaudio-plugin", null, "*.rpm", 1), - [".smd"] = new ContentTypeMapping("audio/x-smd", null, "*.smd", 1), - [".smx"] = new ContentTypeMapping("audio/x-smd", null, "*.smx", 1), - [".smz"] = new ContentTypeMapping("audio/x-smd", null, "*.smz", 1), - [".snd"] = new ContentTypeMapping("audio/basic", null, "*.snd", 1), - [".spx"] = new ContentTypeMapping("audio/ogg", null, "*.spx", 1), - [".wav"] = new ContentTypeMapping("audio/wav", null, "*.wav", 1), - [".wax"] = new ContentTypeMapping("audio/x-ms-wax", null, "*.wax", 1), - [".wma"] = new ContentTypeMapping("audio/x-ms-wma", null, "*.wma", 1), - [".accdb"] = new ContentTypeMapping("application/msaccess", null, "*.accdb", 1), - [".accde"] = new ContentTypeMapping("application/msaccess", null, "*.accde", 1), - [".accdt"] = new ContentTypeMapping("application/msaccess", null, "*.accdt", 1), - [".acx"] = new ContentTypeMapping("application/internet-property-stream", null, "*.acx", 1), - [".ai"] = new ContentTypeMapping("application/postscript", null, "*.ai", 1), - [".application"] = new ContentTypeMapping("application/x-ms-application", null, "*.application", 1), - [".atom"] = new ContentTypeMapping("application/atom+xml", null, "*.atom", 1), - [".axs"] = new ContentTypeMapping("application/olescript", null, "*.axs", 1), - [".bcpio"] = new ContentTypeMapping("application/x-bcpio", null, "*.bcpio", 1), - [".cab"] = new ContentTypeMapping("application/vnd.ms-cab-compressed", null, "*.cab", 1), - [".calx"] = new ContentTypeMapping("application/vnd.ms-office.calx", null, "*.calx", 1), - [".cat"] = new ContentTypeMapping("application/vnd.ms-pki.seccat", null, "*.cat", 1), - [".cdf"] = new ContentTypeMapping("application/x-cdf", null, "*.cdf", 1), - [".class"] = new ContentTypeMapping("application/x-java-applet", null, "*.class", 1), - [".clp"] = new ContentTypeMapping("application/x-msclip", null, "*.clp", 1), - [".cpio"] = new ContentTypeMapping("application/x-cpio", null, "*.cpio", 1), - [".crd"] = new ContentTypeMapping("application/x-mscardfile", null, "*.crd", 1), - [".crl"] = new ContentTypeMapping("application/pkix-crl", null, "*.crl", 1), - [".crt"] = new ContentTypeMapping("application/x-x509-ca-cert", null, "*.crt", 1), - [".csh"] = new ContentTypeMapping("application/x-csh", null, "*.csh", 1), - [".dcr"] = new ContentTypeMapping("application/x-director", null, "*.dcr", 1), - [".der"] = new ContentTypeMapping("application/x-x509-ca-cert", null, "*.der", 1), - [".dir"] = new ContentTypeMapping("application/x-director", null, "*.dir", 1), - [".doc"] = new ContentTypeMapping("application/msword", null, "*.doc", 1), - [".docm"] = new ContentTypeMapping("application/vnd.ms-word.document.macroEnabled.12", null, "*.docm", 1), - [".docx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.wordprocessingml.document", null, "*.docx", 1), - [".dot"] = new ContentTypeMapping("application/msword", null, "*.dot", 1), - [".dotm"] = new ContentTypeMapping("application/vnd.ms-word.template.macroEnabled.12", null, "*.dotm", 1), - [".dotx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.wordprocessingml.template", null, "*.dotx", 1), - [".dvi"] = new ContentTypeMapping("application/x-dvi", null, "*.dvi", 1), - [".dwf"] = new ContentTypeMapping("drawing/x-dwf", null, "*.dwf", 1), - [".dxr"] = new ContentTypeMapping("application/x-director", null, "*.dxr", 1), - [".eml"] = new ContentTypeMapping("message/rfc822", null, "*.eml", 1), - [".eot"] = new ContentTypeMapping("application/vnd.ms-fontobject", null, "*.eot", 1), - [".eps"] = new ContentTypeMapping("application/postscript", null, "*.eps", 1), - [".evy"] = new ContentTypeMapping("application/envoy", null, "*.evy", 1), - [".exe"] = new ContentTypeMapping("application/vnd.microsoft.portable-executable", null, "*.exe", 1), - [".fdf"] = new ContentTypeMapping("application/vnd.fdf", null, "*.fdf", 1), - [".fif"] = new ContentTypeMapping("application/fractals", null, "*.fif", 1), - [".flr"] = new ContentTypeMapping("x-world/x-vrml", null, "*.flr", 1), - [".gtar"] = new ContentTypeMapping("application/x-gtar", null, "*.gtar", 1), - [".hdf"] = new ContentTypeMapping("application/x-hdf", null, "*.hdf", 1), - [".hhc"] = new ContentTypeMapping("application/x-oleobject", null, "*.hhc", 1), - [".hlp"] = new ContentTypeMapping("application/winhlp", null, "*.hlp", 1), - [".hqx"] = new ContentTypeMapping("application/mac-binhex40", null, "*.hqx", 1), - [".hta"] = new ContentTypeMapping("application/hta", null, "*.hta", 1), - [".iii"] = new ContentTypeMapping("application/x-iphone", null, "*.iii", 1), - [".ins"] = new ContentTypeMapping("application/x-internet-signup", null, "*.ins", 1), - [".isp"] = new ContentTypeMapping("application/x-internet-signup", null, "*.isp", 1), - [".jar"] = new ContentTypeMapping("application/java-archive", null, "*.jar", 1), - [".jck"] = new ContentTypeMapping("application/liquidmotion", null, "*.jck", 1), - [".jcz"] = new ContentTypeMapping("application/liquidmotion", null, "*.jcz", 1), - [".latex"] = new ContentTypeMapping("application/x-latex", null, "*.latex", 1), - [".lit"] = new ContentTypeMapping("application/x-ms-reader", null, "*.lit", 1), - [".m13"] = new ContentTypeMapping("application/x-msmediaview", null, "*.m13", 1), - [".m14"] = new ContentTypeMapping("application/x-msmediaview", null, "*.m14", 1), - [".man"] = new ContentTypeMapping("application/x-troff-man", null, "*.man", 1), - [".manifest"] = new ContentTypeMapping("application/x-ms-manifest", null, "*.manifest", 1), - [".mdb"] = new ContentTypeMapping("application/x-msaccess", null, "*.mdb", 1), - [".me"] = new ContentTypeMapping("application/x-troff-me", null, "*.me", 1), - [".mht"] = new ContentTypeMapping("message/rfc822", null, "*.mht", 1), - [".mhtml"] = new ContentTypeMapping("message/rfc822", null, "*.mhtml", 1), - [".mmf"] = new ContentTypeMapping("application/x-smaf", null, "*.mmf", 1), - [".mny"] = new ContentTypeMapping("application/x-msmoney", null, "*.mny", 1), - [".mpp"] = new ContentTypeMapping("application/vnd.ms-project", null, "*.mpp", 1), - [".ms"] = new ContentTypeMapping("application/x-troff-ms", null, "*.ms", 1), - [".mvb"] = new ContentTypeMapping("application/x-msmediaview", null, "*.mvb", 1), - [".mvc"] = new ContentTypeMapping("application/x-miva-compiled", null, "*.mvc", 1), - [".nc"] = new ContentTypeMapping("application/x-netcdf", null, "*.nc", 1), - [".nws"] = new ContentTypeMapping("message/rfc822", null, "*.nws", 1), - [".oda"] = new ContentTypeMapping("application/oda", null, "*.oda", 1), - [".ods"] = new ContentTypeMapping("application/oleobject", null, "*.ods", 1), - [".ogx"] = new ContentTypeMapping("application/ogg", null, "*.ogx", 1), - [".one"] = new ContentTypeMapping("application/onenote", null, "*.one", 1), - [".onea"] = new ContentTypeMapping("application/onenote", null, "*.onea", 1), - [".onetoc"] = new ContentTypeMapping("application/onenote", null, "*.onetoc", 1), - [".onetoc2"] = new ContentTypeMapping("application/onenote", null, "*.onetoc2", 1), - [".onetmp"] = new ContentTypeMapping("application/onenote", null, "*.onetmp", 1), - [".onepkg"] = new ContentTypeMapping("application/onenote", null, "*.onepkg", 1), - [".osdx"] = new ContentTypeMapping("application/opensearchdescription+xml", null, "*.osdx", 1), - [".p10"] = new ContentTypeMapping("application/pkcs10", null, "*.p10", 1), - [".p12"] = new ContentTypeMapping("application/x-pkcs12", null, "*.p12", 1), - [".p7b"] = new ContentTypeMapping("application/x-pkcs7-certificates", null, "*.p7b", 1), - [".p7c"] = new ContentTypeMapping("application/pkcs7-mime", null, "*.p7c", 1), - [".p7m"] = new ContentTypeMapping("application/pkcs7-mime", null, "*.p7m", 1), - [".p7r"] = new ContentTypeMapping("application/x-pkcs7-certreqresp", null, "*.p7r", 1), - [".p7s"] = new ContentTypeMapping("application/pkcs7-signature", null, "*.p7s", 1), - [".pdf"] = new ContentTypeMapping("application/pdf", null, "*.pdf", 1), - [".pfx"] = new ContentTypeMapping("application/x-pkcs12", null, "*.pfx", 1), - [".pko"] = new ContentTypeMapping("application/vnd.ms-pki.pko", null, "*.pko", 1), - [".pma"] = new ContentTypeMapping("application/x-perfmon", null, "*.pma", 1), - [".pmc"] = new ContentTypeMapping("application/x-perfmon", null, "*.pmc", 1), - [".pml"] = new ContentTypeMapping("application/x-perfmon", null, "*.pml", 1), - [".pmr"] = new ContentTypeMapping("application/x-perfmon", null, "*.pmr", 1), - [".pmw"] = new ContentTypeMapping("application/x-perfmon", null, "*.pmw", 1), - [".pot"] = new ContentTypeMapping("application/vnd.ms-powerpoint", null, "*.pot", 1), - [".potm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.template.macroEnabled.12", null, "*.potm", 1), - [".potx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.template", null, "*.potx", 1), - [".ppam"] = new ContentTypeMapping("application/vnd.ms-powerpoint.addin.macroEnabled.12", null, "*.ppam", 1), - [".pps"] = new ContentTypeMapping("application/vnd.ms-powerpoint", null, "*.pps", 1), - [".ppsm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.slideshow.macroEnabled.12", null, "*.ppsm", 1), - [".ppsx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.slideshow", null, "*.ppsx", 1), - [".ppt"] = new ContentTypeMapping("application/vnd.ms-powerpoint", null, "*.ppt", 1), - [".pptm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.presentation.macroEnabled.12", null, "*.pptm", 1), - [".pptx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.presentation", null, "*.pptx", 1), - [".prf"] = new ContentTypeMapping("application/pics-rules", null, "*.prf", 1), - [".ps"] = new ContentTypeMapping("application/postscript", null, "*.ps", 1), - [".pub"] = new ContentTypeMapping("application/x-mspublisher", null, "*.pub", 1), - [".qtl"] = new ContentTypeMapping("application/x-quicktimeplayer", null, "*.qtl", 1), - [".rm"] = new ContentTypeMapping("application/vnd.rn-realmedia", null, "*.rm", 1), - [".roff"] = new ContentTypeMapping("application/x-troff", null, "*.roff", 1), - [".rtf"] = new ContentTypeMapping("application/rtf", null, "*.rtf", 1), - [".scd"] = new ContentTypeMapping("application/x-msschedule", null, "*.scd", 1), - [".setpay"] = new ContentTypeMapping("application/set-payment-initiation", null, "*.setpay", 1), - [".setreg"] = new ContentTypeMapping("application/set-registration-initiation", null, "*.setreg", 1), - [".sh"] = new ContentTypeMapping("application/x-sh", null, "*.sh", 1), - [".shar"] = new ContentTypeMapping("application/x-shar", null, "*.shar", 1), - [".sit"] = new ContentTypeMapping("application/x-stuffit", null, "*.sit", 1), - [".sldm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.slide.macroEnabled.12", null, "*.sldm", 1), - [".sldx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.slide", null, "*.sldx", 1), - [".spc"] = new ContentTypeMapping("application/x-pkcs7-certificates", null, "*.spc", 1), - [".spl"] = new ContentTypeMapping("application/futuresplash", null, "*.spl", 1), - [".src"] = new ContentTypeMapping("application/x-wais-source", null, "*.src", 1), - [".ssm"] = new ContentTypeMapping("application/streamingmedia", null, "*.ssm", 1), - [".sst"] = new ContentTypeMapping("application/vnd.ms-pki.certstore", null, "*.sst", 1), - [".stl"] = new ContentTypeMapping("application/vnd.ms-pki.stl", null, "*.stl", 1), - [".sv4cpio"] = new ContentTypeMapping("application/x-sv4cpio", null, "*.sv4cpio", 1), - [".sv4crc"] = new ContentTypeMapping("application/x-sv4crc", null, "*.sv4crc", 1), - [".swf"] = new ContentTypeMapping("application/x-shockwave-flash", null, "*.swf", 1), - [".t"] = new ContentTypeMapping("application/x-troff", null, "*.t", 1), - [".tar"] = new ContentTypeMapping("application/x-tar", null, "*.tar", 1), - [".tcl"] = new ContentTypeMapping("application/x-tcl", null, "*.tcl", 1), - [".tex"] = new ContentTypeMapping("application/x-tex", null, "*.tex", 1), - [".texi"] = new ContentTypeMapping("application/x-texinfo", null, "*.texi", 1), - [".texinfo"] = new ContentTypeMapping("application/x-texinfo", null, "*.texinfo", 1), - [".tgz"] = new ContentTypeMapping("application/x-compressed", null, "*.tgz", 1), - [".thmx"] = new ContentTypeMapping("application/vnd.ms-officetheme", null, "*.thmx", 1), - [".tr"] = new ContentTypeMapping("application/x-troff", null, "*.tr", 1), - [".trm"] = new ContentTypeMapping("application/x-msterminal", null, "*.trm", 1), - [".ttc"] = new ContentTypeMapping("application/x-font-ttf", null, "*.ttc", 1), - [".ttf"] = new ContentTypeMapping("application/x-font-ttf", null, "*.ttf", 1), - [".ustar"] = new ContentTypeMapping("application/x-ustar", null, "*.ustar", 1), - [".vdx"] = new ContentTypeMapping("application/vnd.ms-visio.viewer", null, "*.vdx", 1), - [".vsd"] = new ContentTypeMapping("application/vnd.visio", null, "*.vsd", 1), - [".vss"] = new ContentTypeMapping("application/vnd.visio", null, "*.vss", 1), - [".vst"] = new ContentTypeMapping("application/vnd.visio", null, "*.vst", 1), - [".vsto"] = new ContentTypeMapping("application/x-ms-vsto", null, "*.vsto", 1), - [".vsw"] = new ContentTypeMapping("application/vnd.visio", null, "*.vsw", 1), - [".vsx"] = new ContentTypeMapping("application/vnd.visio", null, "*.vsx", 1), - [".vtx"] = new ContentTypeMapping("application/vnd.visio", null, "*.vtx", 1), - [".wcm"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wcm", 1), - [".wdb"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wdb", 1), - [".wks"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wks", 1), - [".wmd"] = new ContentTypeMapping("application/x-ms-wmd", null, "*.wmd", 1), - [".wmf"] = new ContentTypeMapping("application/x-msmetafile", null, "*.wmf", 1), - [".wmlc"] = new ContentTypeMapping("application/vnd.wap.wmlc", null, "*.wmlc", 1), - [".wmlsc"] = new ContentTypeMapping("application/vnd.wap.wmlscriptc", null, "*.wmlsc", 1), - [".wmz"] = new ContentTypeMapping("application/x-ms-wmz", null, "*.wmz", 1), - [".wps"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wps", 1), - [".wri"] = new ContentTypeMapping("application/x-mswrite", null, "*.wri", 1), - [".wrl"] = new ContentTypeMapping("x-world/x-vrml", null, "*.wrl", 1), - [".wrz"] = new ContentTypeMapping("x-world/x-vrml", null, "*.wrz", 1), - [".x"] = new ContentTypeMapping("application/directx", null, "*.x", 1), - [".xaf"] = new ContentTypeMapping("x-world/x-vrml", null, "*.xaf", 1), - [".xaml"] = new ContentTypeMapping("application/xaml+xml", null, "*.xaml", 1), - [".xap"] = new ContentTypeMapping("application/x-silverlight-app", null, "*.xap", 1), - [".xbap"] = new ContentTypeMapping("application/x-ms-xbap", null, "*.xbap", 1), - [".xht"] = new ContentTypeMapping("application/xhtml+xml", null, "*.xht", 1), - [".xhtml"] = new ContentTypeMapping("application/xhtml+xml", null, "*.xhtml", 1), - [".xla"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xla", 1), - [".xlam"] = new ContentTypeMapping("application/vnd.ms-excel.addin.macroEnabled.12", null, "*.xlam", 1), - [".xlc"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlc", 1), - [".xlm"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlm", 1), - [".xls"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xls", 1), - [".xlsb"] = new ContentTypeMapping("application/vnd.ms-excel.sheet.binary.macroEnabled.12", null, "*.xlsb", 1), - [".xlsm"] = new ContentTypeMapping("application/vnd.ms-excel.sheet.macroEnabled.12", null, "*.xlsm", 1), - [".xlsx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", null, "*.xlsx", 1), - [".xlt"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlt", 1), - [".xltm"] = new ContentTypeMapping("application/vnd.ms-excel.template.macroEnabled.12", null, "*.xltm", 1), - [".xltx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.spreadsheetml.template", null, "*.xltx", 1), - [".xlw"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlw", 1), - [".xof"] = new ContentTypeMapping("x-world/x-vrml", null, "*.xof", 1), - [".xps"] = new ContentTypeMapping("application/vnd.ms-xpsdocument", null, "*.xps", 1), - [".z"] = new ContentTypeMapping("application/x-compress", null, "*.z", 1), - [".zip"] = new ContentTypeMapping("application/x-zip-compressed", null, "*.zip", 1), - [".aaf"] = new ContentTypeMapping("application/octet-stream", null, "*.aaf", 1), - [".aca"] = new ContentTypeMapping("application/octet-stream", null, "*.aca", 1), - [".afm"] = new ContentTypeMapping("application/octet-stream", null, "*.afm", 1), - [".asd"] = new ContentTypeMapping("application/octet-stream", null, "*.asd", 1), - [".asi"] = new ContentTypeMapping("application/octet-stream", null, "*.asi", 1), - [".bin"] = new ContentTypeMapping("application/octet-stream", null, "*.bin", 1), - [".chm"] = new ContentTypeMapping("application/octet-stream", null, "*.chm", 1), - [".cur"] = new ContentTypeMapping("application/octet-stream", null, "*.cur", 1), - [".deploy"] = new ContentTypeMapping("application/octet-stream", null, "*.deploy", 1), - [".dsp"] = new ContentTypeMapping("application/octet-stream", null, "*.dsp", 1), - [".dwp"] = new ContentTypeMapping("application/octet-stream", null, "*.dwp", 1), - [".emz"] = new ContentTypeMapping("application/octet-stream", null, "*.emz", 1), - [".fla"] = new ContentTypeMapping("application/octet-stream", null, "*.fla", 1), - [".hhk"] = new ContentTypeMapping("application/octet-stream", null, "*.hhk", 1), - [".hhp"] = new ContentTypeMapping("application/octet-stream", null, "*.hhp", 1), - [".inf"] = new ContentTypeMapping("application/octet-stream", null, "*.inf", 1), - [".java"] = new ContentTypeMapping("application/octet-stream", null, "*.java", 1), - [".jpb"] = new ContentTypeMapping("application/octet-stream", null, "*.jpb", 1), - [".lpk"] = new ContentTypeMapping("application/octet-stream", null, "*.lpk", 1), - [".lzh"] = new ContentTypeMapping("application/octet-stream", null, "*.lzh", 1), - [".mdp"] = new ContentTypeMapping("application/octet-stream", null, "*.mdp", 1), - [".mix"] = new ContentTypeMapping("application/octet-stream", null, "*.mix", 1), - [".msi"] = new ContentTypeMapping("application/octet-stream", null, "*.msi", 1), - [".mso"] = new ContentTypeMapping("application/octet-stream", null, "*.mso", 1), - [".ocx"] = new ContentTypeMapping("application/octet-stream", null, "*.ocx", 1), - [".pcx"] = new ContentTypeMapping("application/octet-stream", null, "*.pcx", 1), - [".pcz"] = new ContentTypeMapping("application/octet-stream", null, "*.pcz", 1), - [".pfb"] = new ContentTypeMapping("application/octet-stream", null, "*.pfb", 1), - [".pfm"] = new ContentTypeMapping("application/octet-stream", null, "*.pfm", 1), - [".prm"] = new ContentTypeMapping("application/octet-stream", null, "*.prm", 1), - [".prx"] = new ContentTypeMapping("application/octet-stream", null, "*.prx", 1), - [".psd"] = new ContentTypeMapping("application/octet-stream", null, "*.psd", 1), - [".psm"] = new ContentTypeMapping("application/octet-stream", null, "*.psm", 1), - [".psp"] = new ContentTypeMapping("application/octet-stream", null, "*.psp", 1), - [".qxd"] = new ContentTypeMapping("application/octet-stream", null, "*.qxd", 1), - [".rar"] = new ContentTypeMapping("application/octet-stream", null, "*.rar", 1), - [".sea"] = new ContentTypeMapping("application/octet-stream", null, "*.sea", 1), - [".smi"] = new ContentTypeMapping("application/octet-stream", null, "*.smi", 1), - [".snp"] = new ContentTypeMapping("application/octet-stream", null, "*.snp", 1), - [".thn"] = new ContentTypeMapping("application/octet-stream", null, "*.thn", 1), - [".toc"] = new ContentTypeMapping("application/octet-stream", null, "*.toc", 1), - [".u32"] = new ContentTypeMapping("application/octet-stream", null, "*.u32", 1), - [".xsn"] = new ContentTypeMapping("application/octet-stream", null, "*.xsn", 1), - [".xtp"] = new ContentTypeMapping("application/octet-stream", null, "*.xtp", 1), + ["*.js"] = new ContentTypeMapping("text/javascript", null, "*.js", 1), + ["*.css"] = new ContentTypeMapping("text/css", null, "*.css", 1), + ["*.html"] = new ContentTypeMapping("text/html", null, "*.html", 1), + ["*.json"] = new ContentTypeMapping("application/json", null, "*.json", 1), + ["*.mjs"] = new ContentTypeMapping("text/javascript", null, "*.mjs", 1), + ["*.xml"] = new ContentTypeMapping("text/xml", null, "*.xml", 1), + ["*.htm"] = new ContentTypeMapping("text/html", null, "*.htm", 1), + ["*.wasm"] = new ContentTypeMapping("application/wasm", null, "*.wasm", 1), + ["*.txt"] = new ContentTypeMapping("text/plain", null, "*.txt", 1), + ["*.dll"] = new ContentTypeMapping("application/octet-stream", null, "*.dll", 1), + ["*.pdb"] = new ContentTypeMapping("application/octet-stream", null, "*.pdb", 1), + ["*.dat"] = new ContentTypeMapping("application/octet-stream", null, "*.dat", 1), + ["*.webmanifest"] = new ContentTypeMapping("application/manifest+json", null, "*.webmanifest", 1), + ["*.jsx"] = new ContentTypeMapping("text/jscript", null, "*.jsx", 1), + ["*.markdown"] = new ContentTypeMapping("text/markdown", null, "*.markdown", 1), + ["*.gz"] = new ContentTypeMapping("application/x-gzip", null, "*.gz", 1), + ["*.br"] = new ContentTypeMapping("application/octet-stream", null, "*.br", 1), + ["*.md"] = new ContentTypeMapping("text/markdown", null, "*.md", 1), + ["*.bmp"] = new ContentTypeMapping("image/bmp", null, "*.bmp", 1), + ["*.jpeg"] = new ContentTypeMapping("image/jpeg", null, "*.jpeg", 1), + ["*.jpg"] = new ContentTypeMapping("image/jpeg", null, "*.jpg", 1), + ["*.gif"] = new ContentTypeMapping("image/gif", null, "*.gif", 1), + ["*.svg"] = new ContentTypeMapping("image/svg+xml", null, "*.svg", 1), + ["*.png"] = new ContentTypeMapping("image/png", null, "*.png", 1), + ["*.webp"] = new ContentTypeMapping("image/webp", null, "*.webp", 1), + ["*.otf"] = new ContentTypeMapping("font/otf", null, "*.otf", 1), + ["*.woff2"] = new ContentTypeMapping("font/woff2", null, "*.woff2", 1), + ["*.m4v"] = new ContentTypeMapping("video/mp4", null, "*.m4v", 1), + ["*.mov"] = new ContentTypeMapping("video/quicktime", null, "*.mov", 1), + ["*.movie"] = new ContentTypeMapping("video/x-sgi-movie", null, "*.movie", 1), + ["*.mp2"] = new ContentTypeMapping("video/mpeg", null, "*.mp2", 1), + ["*.mp4"] = new ContentTypeMapping("video/mp4", null, "*.mp4", 1), + ["*.mp4v"] = new ContentTypeMapping("video/mp4", null, "*.mp4v", 1), + ["*.mpa"] = new ContentTypeMapping("video/mpeg", null, "*.mpa", 1), + ["*.mpe"] = new ContentTypeMapping("video/mpeg", null, "*.mpe", 1), + ["*.mpeg"] = new ContentTypeMapping("video/mpeg", null, "*.mpeg", 1), + ["*.mpg"] = new ContentTypeMapping("video/mpeg", null, "*.mpg", 1), + ["*.mpv2"] = new ContentTypeMapping("video/mpeg", null, "*.mpv2", 1), + ["*.nsc"] = new ContentTypeMapping("video/x-ms-asf", null, "*.nsc", 1), + ["*.ogg"] = new ContentTypeMapping("video/ogg", null, "*.ogg", 1), + ["*.ogv"] = new ContentTypeMapping("video/ogg", null, "*.ogv", 1), + ["*.webm"] = new ContentTypeMapping("video/webm", null, "*.webm", 1), + ["*.323"] = new ContentTypeMapping("text/h323", null, "*.323", 1), + ["*.appcache"] = new ContentTypeMapping("text/cache-manifest", null, "*.appcache", 1), + ["*.asm"] = new ContentTypeMapping("text/plain", null, "*.asm", 1), + ["*.bas"] = new ContentTypeMapping("text/plain", null, "*.bas", 1), + ["*.c"] = new ContentTypeMapping("text/plain", null, "*.c", 1), + ["*.cnf"] = new ContentTypeMapping("text/plain", null, "*.cnf", 1), + ["*.cpp"] = new ContentTypeMapping("text/plain", null, "*.cpp", 1), + ["*.csv"] = new ContentTypeMapping("text/csv", null, "*.csv", 1), + ["*.disco"] = new ContentTypeMapping("text/xml", null, "*.disco", 1), + ["*.dlm"] = new ContentTypeMapping("text/dlm", null, "*.dlm", 1), + ["*.dtd"] = new ContentTypeMapping("text/xml", null, "*.dtd", 1), + ["*.etx"] = new ContentTypeMapping("text/x-setext", null, "*.etx", 1), + ["*.h"] = new ContentTypeMapping("text/plain", null, "*.h", 1), + ["*.hdml"] = new ContentTypeMapping("text/x-hdml", null, "*.hdml", 1), + ["*.htc"] = new ContentTypeMapping("text/x-component", null, "*.htc", 1), + ["*.htt"] = new ContentTypeMapping("text/webviewhtml", null, "*.htt", 1), + ["*.hxt"] = new ContentTypeMapping("text/html", null, "*.hxt", 1), + ["*.ical"] = new ContentTypeMapping("text/calendar", null, "*.ical", 1), + ["*.icalendar"] = new ContentTypeMapping("text/calendar", null, "*.icalendar", 1), + ["*.ics"] = new ContentTypeMapping("text/calendar", null, "*.ics", 1), + ["*.ifb"] = new ContentTypeMapping("text/calendar", null, "*.ifb", 1), + ["*.map"] = new ContentTypeMapping("text/plain", null, "*.map", 1), + ["*.mno"] = new ContentTypeMapping("text/xml", null, "*.mno", 1), + ["*.odc"] = new ContentTypeMapping("text/x-ms-odc", null, "*.odc", 1), + ["*.rtx"] = new ContentTypeMapping("text/richtext", null, "*.rtx", 1), + ["*.sct"] = new ContentTypeMapping("text/scriptlet", null, "*.sct", 1), + ["*.sgml"] = new ContentTypeMapping("text/sgml", null, "*.sgml", 1), + ["*.tsv"] = new ContentTypeMapping("text/tab-separated-values", null, "*.tsv", 1), + ["*.uls"] = new ContentTypeMapping("text/iuls", null, "*.uls", 1), + ["*.vbs"] = new ContentTypeMapping("text/vbscript", null, "*.vbs", 1), + ["*.vcf"] = new ContentTypeMapping("text/x-vcard", null, "*.vcf", 1), + ["*.vcs"] = new ContentTypeMapping("text/plain", null, "*.vcs", 1), + ["*.vml"] = new ContentTypeMapping("text/xml", null, "*.vml", 1), + ["*.wml"] = new ContentTypeMapping("text/vnd.wap.wml", null, "*.wml", 1), + ["*.wmls"] = new ContentTypeMapping("text/vnd.wap.wmlscript", null, "*.wmls", 1), + ["*.wsdl"] = new ContentTypeMapping("text/xml", null, "*.wsdl", 1), + ["*.xdr"] = new ContentTypeMapping("text/plain", null, "*.xdr", 1), + ["*.xsd"] = new ContentTypeMapping("text/xml", null, "*.xsd", 1), + ["*.xsf"] = new ContentTypeMapping("text/xml", null, "*.xsf", 1), + ["*.xsl"] = new ContentTypeMapping("text/xml", null, "*.xsl", 1), + ["*.xslt"] = new ContentTypeMapping("text/xml", null, "*.xslt", 1), + ["*.woff"] = new ContentTypeMapping("application/font-woff", null, "*.woff", 1), + ["*.art"] = new ContentTypeMapping("image/x-jg", null, "*.art", 1), + ["*.cmx"] = new ContentTypeMapping("image/x-cmx", null, "*.cmx", 1), + ["*.cod"] = new ContentTypeMapping("image/cis-cod", null, "*.cod", 1), + ["*.dib"] = new ContentTypeMapping("image/bmp", null, "*.dib", 1), + ["*.ico"] = new ContentTypeMapping("image/x-icon", null, "*.ico", 1), + ["*.ief"] = new ContentTypeMapping("image/ief", null, "*.ief", 1), + ["*.jfif"] = new ContentTypeMapping("image/pjpeg", null, "*.jfif", 1), + ["*.jpe"] = new ContentTypeMapping("image/jpeg", null, "*.jpe", 1), + ["*.pbm"] = new ContentTypeMapping("image/x-portable-bitmap", null, "*.pbm", 1), + ["*.pgm"] = new ContentTypeMapping("image/x-portable-graymap", null, "*.pgm", 1), + ["*.pnm"] = new ContentTypeMapping("image/x-portable-anymap", null, "*.pnm", 1), + ["*.pnz"] = new ContentTypeMapping("image/png", null, "*.pnz", 1), + ["*.ppm"] = new ContentTypeMapping("image/x-portable-pixmap", null, "*.ppm", 1), + ["*.ras"] = new ContentTypeMapping("image/x-cmu-raster", null, "*.ras", 1), + ["*.rf"] = new ContentTypeMapping("image/vnd.rn-realflash", null, "*.rf", 1), + ["*.rgb"] = new ContentTypeMapping("image/x-rgb", null, "*.rgb", 1), + ["*.svgz"] = new ContentTypeMapping("image/svg+xml", null, "*.svgz", 1), + ["*.tif"] = new ContentTypeMapping("image/tiff", null, "*.tif", 1), + ["*.tiff"] = new ContentTypeMapping("image/tiff", null, "*.tiff", 1), + ["*.wbmp"] = new ContentTypeMapping("image/vnd.wap.wbmp", null, "*.wbmp", 1), + ["*.xbm"] = new ContentTypeMapping("image/x-xbitmap", null, "*.xbm", 1), + ["*.xpm"] = new ContentTypeMapping("image/x-xpixmap", null, "*.xpm", 1), + ["*.xwd"] = new ContentTypeMapping("image/x-xwindowdump", null, "*.xwd", 1), + ["*.3g2"] = new ContentTypeMapping("video/3gpp2", null, "*.3g2", 1), + ["*.3gp2"] = new ContentTypeMapping("video/3gpp2", null, "*.3gp2", 1), + ["*.3gp"] = new ContentTypeMapping("video/3gpp", null, "*.3gp", 1), + ["*.3gpp"] = new ContentTypeMapping("video/3gpp", null, "*.3gpp", 1), + ["*.asf"] = new ContentTypeMapping("video/x-ms-asf", null, "*.asf", 1), + ["*.asr"] = new ContentTypeMapping("video/x-ms-asf", null, "*.asr", 1), + ["*.asx"] = new ContentTypeMapping("video/x-ms-asf", null, "*.asx", 1), + ["*.avi"] = new ContentTypeMapping("video/x-msvideo", null, "*.avi", 1), + ["*.dvr"] = new ContentTypeMapping("video/x-ms-dvr", null, "*.dvr", 1), + ["*.flv"] = new ContentTypeMapping("video/x-flv", null, "*.flv", 1), + ["*.IVF"] = new ContentTypeMapping("video/x-ivf", null, "*.IVF", 1), + ["*.lsf"] = new ContentTypeMapping("video/x-la-asf", null, "*.lsf", 1), + ["*.lsx"] = new ContentTypeMapping("video/x-la-asf", null, "*.lsx", 1), + ["*.m1v"] = new ContentTypeMapping("video/mpeg", null, "*.m1v", 1), + ["*.m2ts"] = new ContentTypeMapping("video/vnd.dlna.mpeg-tts", null, "*.m2ts", 1), + ["*.qt"] = new ContentTypeMapping("video/quicktime", null, "*.qt", 1), + ["*.ts"] = new ContentTypeMapping("video/vnd.dlna.mpeg-tts", null, "*.ts", 1), + ["*.tts"] = new ContentTypeMapping("video/vnd.dlna.mpeg-tts", null, "*.tts", 1), + ["*.wm"] = new ContentTypeMapping("video/x-ms-wm", null, "*.wm", 1), + ["*.wmp"] = new ContentTypeMapping("video/x-ms-wmp", null, "*.wmp", 1), + ["*.wmv"] = new ContentTypeMapping("video/x-ms-wmv", null, "*.wmv", 1), + ["*.wmx"] = new ContentTypeMapping("video/x-ms-wmx", null, "*.wmx", 1), + ["*.wtv"] = new ContentTypeMapping("video/x-ms-wtv", null, "*.wtv", 1), + ["*.wvx"] = new ContentTypeMapping("video/x-ms-wvx", null, "*.wvx", 1), + ["*.aac"] = new ContentTypeMapping("audio/aac", null, "*.aac", 1), + ["*.adt"] = new ContentTypeMapping("audio/vnd.dlna.adts", null, "*.adt", 1), + ["*.adts"] = new ContentTypeMapping("audio/vnd.dlna.adts", null, "*.adts", 1), + ["*.aif"] = new ContentTypeMapping("audio/x-aiff", null, "*.aif", 1), + ["*.aifc"] = new ContentTypeMapping("audio/aiff", null, "*.aifc", 1), + ["*.aiff"] = new ContentTypeMapping("audio/aiff", null, "*.aiff", 1), + ["*.au"] = new ContentTypeMapping("audio/basic", null, "*.au", 1), + ["*.m3u"] = new ContentTypeMapping("audio/x-mpegurl", null, "*.m3u", 1), + ["*.m4a"] = new ContentTypeMapping("audio/mp4", null, "*.m4a", 1), + ["*.mid"] = new ContentTypeMapping("audio/mid", null, "*.mid", 1), + ["*.midi"] = new ContentTypeMapping("audio/mid", null, "*.midi", 1), + ["*.mp3"] = new ContentTypeMapping("audio/mpeg", null, "*.mp3", 1), + ["*.oga"] = new ContentTypeMapping("audio/ogg", null, "*.oga", 1), + ["*.ra"] = new ContentTypeMapping("audio/x-pn-realaudio", null, "*.ra", 1), + ["*.ram"] = new ContentTypeMapping("audio/x-pn-realaudio", null, "*.ram", 1), + ["*.rmi"] = new ContentTypeMapping("audio/mid", null, "*.rmi", 1), + ["*.rpm"] = new ContentTypeMapping("audio/x-pn-realaudio-plugin", null, "*.rpm", 1), + ["*.smd"] = new ContentTypeMapping("audio/x-smd", null, "*.smd", 1), + ["*.smx"] = new ContentTypeMapping("audio/x-smd", null, "*.smx", 1), + ["*.smz"] = new ContentTypeMapping("audio/x-smd", null, "*.smz", 1), + ["*.snd"] = new ContentTypeMapping("audio/basic", null, "*.snd", 1), + ["*.spx"] = new ContentTypeMapping("audio/ogg", null, "*.spx", 1), + ["*.wav"] = new ContentTypeMapping("audio/wav", null, "*.wav", 1), + ["*.wax"] = new ContentTypeMapping("audio/x-ms-wax", null, "*.wax", 1), + ["*.wma"] = new ContentTypeMapping("audio/x-ms-wma", null, "*.wma", 1), + ["*.accdb"] = new ContentTypeMapping("application/msaccess", null, "*.accdb", 1), + ["*.accde"] = new ContentTypeMapping("application/msaccess", null, "*.accde", 1), + ["*.accdt"] = new ContentTypeMapping("application/msaccess", null, "*.accdt", 1), + ["*.acx"] = new ContentTypeMapping("application/internet-property-stream", null, "*.acx", 1), + ["*.ai"] = new ContentTypeMapping("application/postscript", null, "*.ai", 1), + ["*.application"] = new ContentTypeMapping("application/x-ms-application", null, "*.application", 1), + ["*.atom"] = new ContentTypeMapping("application/atom+xml", null, "*.atom", 1), + ["*.axs"] = new ContentTypeMapping("application/olescript", null, "*.axs", 1), + ["*.bcpio"] = new ContentTypeMapping("application/x-bcpio", null, "*.bcpio", 1), + ["*.cab"] = new ContentTypeMapping("application/vnd.ms-cab-compressed", null, "*.cab", 1), + ["*.calx"] = new ContentTypeMapping("application/vnd.ms-office.calx", null, "*.calx", 1), + ["*.cat"] = new ContentTypeMapping("application/vnd.ms-pki.seccat", null, "*.cat", 1), + ["*.cdf"] = new ContentTypeMapping("application/x-cdf", null, "*.cdf", 1), + ["*.class"] = new ContentTypeMapping("application/x-java-applet", null, "*.class", 1), + ["*.clp"] = new ContentTypeMapping("application/x-msclip", null, "*.clp", 1), + ["*.cpio"] = new ContentTypeMapping("application/x-cpio", null, "*.cpio", 1), + ["*.crd"] = new ContentTypeMapping("application/x-mscardfile", null, "*.crd", 1), + ["*.crl"] = new ContentTypeMapping("application/pkix-crl", null, "*.crl", 1), + ["*.crt"] = new ContentTypeMapping("application/x-x509-ca-cert", null, "*.crt", 1), + ["*.csh"] = new ContentTypeMapping("application/x-csh", null, "*.csh", 1), + ["*.dcr"] = new ContentTypeMapping("application/x-director", null, "*.dcr", 1), + ["*.der"] = new ContentTypeMapping("application/x-x509-ca-cert", null, "*.der", 1), + ["*.dir"] = new ContentTypeMapping("application/x-director", null, "*.dir", 1), + ["*.doc"] = new ContentTypeMapping("application/msword", null, "*.doc", 1), + ["*.docm"] = new ContentTypeMapping("application/vnd.ms-word.document.macroEnabled.12", null, "*.docm", 1), + ["*.docx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.wordprocessingml.document", null, "*.docx", 1), + ["*.dot"] = new ContentTypeMapping("application/msword", null, "*.dot", 1), + ["*.dotm"] = new ContentTypeMapping("application/vnd.ms-word.template.macroEnabled.12", null, "*.dotm", 1), + ["*.dotx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.wordprocessingml.template", null, "*.dotx", 1), + ["*.dvi"] = new ContentTypeMapping("application/x-dvi", null, "*.dvi", 1), + ["*.dwf"] = new ContentTypeMapping("drawing/x-dwf", null, "*.dwf", 1), + ["*.dxr"] = new ContentTypeMapping("application/x-director", null, "*.dxr", 1), + ["*.eml"] = new ContentTypeMapping("message/rfc822", null, "*.eml", 1), + ["*.eot"] = new ContentTypeMapping("application/vnd.ms-fontobject", null, "*.eot", 1), + ["*.eps"] = new ContentTypeMapping("application/postscript", null, "*.eps", 1), + ["*.evy"] = new ContentTypeMapping("application/envoy", null, "*.evy", 1), + ["*.exe"] = new ContentTypeMapping("application/vnd.microsoft.portable-executable", null, "*.exe", 1), + ["*.fdf"] = new ContentTypeMapping("application/vnd.fdf", null, "*.fdf", 1), + ["*.fif"] = new ContentTypeMapping("application/fractals", null, "*.fif", 1), + ["*.flr"] = new ContentTypeMapping("x-world/x-vrml", null, "*.flr", 1), + ["*.gtar"] = new ContentTypeMapping("application/x-gtar", null, "*.gtar", 1), + ["*.hdf"] = new ContentTypeMapping("application/x-hdf", null, "*.hdf", 1), + ["*.hhc"] = new ContentTypeMapping("application/x-oleobject", null, "*.hhc", 1), + ["*.hlp"] = new ContentTypeMapping("application/winhlp", null, "*.hlp", 1), + ["*.hqx"] = new ContentTypeMapping("application/mac-binhex40", null, "*.hqx", 1), + ["*.hta"] = new ContentTypeMapping("application/hta", null, "*.hta", 1), + ["*.iii"] = new ContentTypeMapping("application/x-iphone", null, "*.iii", 1), + ["*.ins"] = new ContentTypeMapping("application/x-internet-signup", null, "*.ins", 1), + ["*.isp"] = new ContentTypeMapping("application/x-internet-signup", null, "*.isp", 1), + ["*.jar"] = new ContentTypeMapping("application/java-archive", null, "*.jar", 1), + ["*.jck"] = new ContentTypeMapping("application/liquidmotion", null, "*.jck", 1), + ["*.jcz"] = new ContentTypeMapping("application/liquidmotion", null, "*.jcz", 1), + ["*.latex"] = new ContentTypeMapping("application/x-latex", null, "*.latex", 1), + ["*.lit"] = new ContentTypeMapping("application/x-ms-reader", null, "*.lit", 1), + ["*.m13"] = new ContentTypeMapping("application/x-msmediaview", null, "*.m13", 1), + ["*.m14"] = new ContentTypeMapping("application/x-msmediaview", null, "*.m14", 1), + ["*.man"] = new ContentTypeMapping("application/x-troff-man", null, "*.man", 1), + ["*.manifest"] = new ContentTypeMapping("application/x-ms-manifest", null, "*.manifest", 1), + ["*.mdb"] = new ContentTypeMapping("application/x-msaccess", null, "*.mdb", 1), + ["*.me"] = new ContentTypeMapping("application/x-troff-me", null, "*.me", 1), + ["*.mht"] = new ContentTypeMapping("message/rfc822", null, "*.mht", 1), + ["*.mhtml"] = new ContentTypeMapping("message/rfc822", null, "*.mhtml", 1), + ["*.mmf"] = new ContentTypeMapping("application/x-smaf", null, "*.mmf", 1), + ["*.mny"] = new ContentTypeMapping("application/x-msmoney", null, "*.mny", 1), + ["*.mpp"] = new ContentTypeMapping("application/vnd.ms-project", null, "*.mpp", 1), + ["*.ms"] = new ContentTypeMapping("application/x-troff-ms", null, "*.ms", 1), + ["*.mvb"] = new ContentTypeMapping("application/x-msmediaview", null, "*.mvb", 1), + ["*.mvc"] = new ContentTypeMapping("application/x-miva-compiled", null, "*.mvc", 1), + ["*.nc"] = new ContentTypeMapping("application/x-netcdf", null, "*.nc", 1), + ["*.nws"] = new ContentTypeMapping("message/rfc822", null, "*.nws", 1), + ["*.oda"] = new ContentTypeMapping("application/oda", null, "*.oda", 1), + ["*.ods"] = new ContentTypeMapping("application/oleobject", null, "*.ods", 1), + ["*.ogx"] = new ContentTypeMapping("application/ogg", null, "*.ogx", 1), + ["*.one"] = new ContentTypeMapping("application/onenote", null, "*.one", 1), + ["*.onea"] = new ContentTypeMapping("application/onenote", null, "*.onea", 1), + ["*.onetoc"] = new ContentTypeMapping("application/onenote", null, "*.onetoc", 1), + ["*.onetoc2"] = new ContentTypeMapping("application/onenote", null, "*.onetoc2", 1), + ["*.onetmp"] = new ContentTypeMapping("application/onenote", null, "*.onetmp", 1), + ["*.onepkg"] = new ContentTypeMapping("application/onenote", null, "*.onepkg", 1), + ["*.osdx"] = new ContentTypeMapping("application/opensearchdescription+xml", null, "*.osdx", 1), + ["*.p10"] = new ContentTypeMapping("application/pkcs10", null, "*.p10", 1), + ["*.p12"] = new ContentTypeMapping("application/x-pkcs12", null, "*.p12", 1), + ["*.p7b"] = new ContentTypeMapping("application/x-pkcs7-certificates", null, "*.p7b", 1), + ["*.p7c"] = new ContentTypeMapping("application/pkcs7-mime", null, "*.p7c", 1), + ["*.p7m"] = new ContentTypeMapping("application/pkcs7-mime", null, "*.p7m", 1), + ["*.p7r"] = new ContentTypeMapping("application/x-pkcs7-certreqresp", null, "*.p7r", 1), + ["*.p7s"] = new ContentTypeMapping("application/pkcs7-signature", null, "*.p7s", 1), + ["*.pdf"] = new ContentTypeMapping("application/pdf", null, "*.pdf", 1), + ["*.pfx"] = new ContentTypeMapping("application/x-pkcs12", null, "*.pfx", 1), + ["*.pko"] = new ContentTypeMapping("application/vnd.ms-pki.pko", null, "*.pko", 1), + ["*.pma"] = new ContentTypeMapping("application/x-perfmon", null, "*.pma", 1), + ["*.pmc"] = new ContentTypeMapping("application/x-perfmon", null, "*.pmc", 1), + ["*.pml"] = new ContentTypeMapping("application/x-perfmon", null, "*.pml", 1), + ["*.pmr"] = new ContentTypeMapping("application/x-perfmon", null, "*.pmr", 1), + ["*.pmw"] = new ContentTypeMapping("application/x-perfmon", null, "*.pmw", 1), + ["*.pot"] = new ContentTypeMapping("application/vnd.ms-powerpoint", null, "*.pot", 1), + ["*.potm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.template.macroEnabled.12", null, "*.potm", 1), + ["*.potx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.template", null, "*.potx", 1), + ["*.ppam"] = new ContentTypeMapping("application/vnd.ms-powerpoint.addin.macroEnabled.12", null, "*.ppam", 1), + ["*.pps"] = new ContentTypeMapping("application/vnd.ms-powerpoint", null, "*.pps", 1), + ["*.ppsm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.slideshow.macroEnabled.12", null, "*.ppsm", 1), + ["*.ppsx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.slideshow", null, "*.ppsx", 1), + ["*.ppt"] = new ContentTypeMapping("application/vnd.ms-powerpoint", null, "*.ppt", 1), + ["*.pptm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.presentation.macroEnabled.12", null, "*.pptm", 1), + ["*.pptx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.presentation", null, "*.pptx", 1), + ["*.prf"] = new ContentTypeMapping("application/pics-rules", null, "*.prf", 1), + ["*.ps"] = new ContentTypeMapping("application/postscript", null, "*.ps", 1), + ["*.pub"] = new ContentTypeMapping("application/x-mspublisher", null, "*.pub", 1), + ["*.qtl"] = new ContentTypeMapping("application/x-quicktimeplayer", null, "*.qtl", 1), + ["*.rm"] = new ContentTypeMapping("application/vnd.rn-realmedia", null, "*.rm", 1), + ["*.roff"] = new ContentTypeMapping("application/x-troff", null, "*.roff", 1), + ["*.rtf"] = new ContentTypeMapping("application/rtf", null, "*.rtf", 1), + ["*.scd"] = new ContentTypeMapping("application/x-msschedule", null, "*.scd", 1), + ["*.setpay"] = new ContentTypeMapping("application/set-payment-initiation", null, "*.setpay", 1), + ["*.setreg"] = new ContentTypeMapping("application/set-registration-initiation", null, "*.setreg", 1), + ["*.sh"] = new ContentTypeMapping("application/x-sh", null, "*.sh", 1), + ["*.shar"] = new ContentTypeMapping("application/x-shar", null, "*.shar", 1), + ["*.sit"] = new ContentTypeMapping("application/x-stuffit", null, "*.sit", 1), + ["*.sldm"] = new ContentTypeMapping("application/vnd.ms-powerpoint.slide.macroEnabled.12", null, "*.sldm", 1), + ["*.sldx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.presentationml.slide", null, "*.sldx", 1), + ["*.spc"] = new ContentTypeMapping("application/x-pkcs7-certificates", null, "*.spc", 1), + ["*.spl"] = new ContentTypeMapping("application/futuresplash", null, "*.spl", 1), + ["*.src"] = new ContentTypeMapping("application/x-wais-source", null, "*.src", 1), + ["*.ssm"] = new ContentTypeMapping("application/streamingmedia", null, "*.ssm", 1), + ["*.sst"] = new ContentTypeMapping("application/vnd.ms-pki.certstore", null, "*.sst", 1), + ["*.stl"] = new ContentTypeMapping("application/vnd.ms-pki.stl", null, "*.stl", 1), + ["*.sv4cpio"] = new ContentTypeMapping("application/x-sv4cpio", null, "*.sv4cpio", 1), + ["*.sv4crc"] = new ContentTypeMapping("application/x-sv4crc", null, "*.sv4crc", 1), + ["*.swf"] = new ContentTypeMapping("application/x-shockwave-flash", null, "*.swf", 1), + ["*.t"] = new ContentTypeMapping("application/x-troff", null, "*.t", 1), + ["*.tar"] = new ContentTypeMapping("application/x-tar", null, "*.tar", 1), + ["*.tcl"] = new ContentTypeMapping("application/x-tcl", null, "*.tcl", 1), + ["*.tex"] = new ContentTypeMapping("application/x-tex", null, "*.tex", 1), + ["*.texi"] = new ContentTypeMapping("application/x-texinfo", null, "*.texi", 1), + ["*.texinfo"] = new ContentTypeMapping("application/x-texinfo", null, "*.texinfo", 1), + ["*.tgz"] = new ContentTypeMapping("application/x-compressed", null, "*.tgz", 1), + ["*.thmx"] = new ContentTypeMapping("application/vnd.ms-officetheme", null, "*.thmx", 1), + ["*.tr"] = new ContentTypeMapping("application/x-troff", null, "*.tr", 1), + ["*.trm"] = new ContentTypeMapping("application/x-msterminal", null, "*.trm", 1), + ["*.ttc"] = new ContentTypeMapping("application/x-font-ttf", null, "*.ttc", 1), + ["*.ttf"] = new ContentTypeMapping("application/x-font-ttf", null, "*.ttf", 1), + ["*.ustar"] = new ContentTypeMapping("application/x-ustar", null, "*.ustar", 1), + ["*.vdx"] = new ContentTypeMapping("application/vnd.ms-visio.viewer", null, "*.vdx", 1), + ["*.vsd"] = new ContentTypeMapping("application/vnd.visio", null, "*.vsd", 1), + ["*.vss"] = new ContentTypeMapping("application/vnd.visio", null, "*.vss", 1), + ["*.vst"] = new ContentTypeMapping("application/vnd.visio", null, "*.vst", 1), + ["*.vsto"] = new ContentTypeMapping("application/x-ms-vsto", null, "*.vsto", 1), + ["*.vsw"] = new ContentTypeMapping("application/vnd.visio", null, "*.vsw", 1), + ["*.vsx"] = new ContentTypeMapping("application/vnd.visio", null, "*.vsx", 1), + ["*.vtx"] = new ContentTypeMapping("application/vnd.visio", null, "*.vtx", 1), + ["*.wcm"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wcm", 1), + ["*.wdb"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wdb", 1), + ["*.wks"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wks", 1), + ["*.wmd"] = new ContentTypeMapping("application/x-ms-wmd", null, "*.wmd", 1), + ["*.wmf"] = new ContentTypeMapping("application/x-msmetafile", null, "*.wmf", 1), + ["*.wmlc"] = new ContentTypeMapping("application/vnd.wap.wmlc", null, "*.wmlc", 1), + ["*.wmlsc"] = new ContentTypeMapping("application/vnd.wap.wmlscriptc", null, "*.wmlsc", 1), + ["*.wmz"] = new ContentTypeMapping("application/x-ms-wmz", null, "*.wmz", 1), + ["*.wps"] = new ContentTypeMapping("application/vnd.ms-works", null, "*.wps", 1), + ["*.wri"] = new ContentTypeMapping("application/x-mswrite", null, "*.wri", 1), + ["*.wrl"] = new ContentTypeMapping("x-world/x-vrml", null, "*.wrl", 1), + ["*.wrz"] = new ContentTypeMapping("x-world/x-vrml", null, "*.wrz", 1), + ["*.x"] = new ContentTypeMapping("application/directx", null, "*.x", 1), + ["*.xaf"] = new ContentTypeMapping("x-world/x-vrml", null, "*.xaf", 1), + ["*.xaml"] = new ContentTypeMapping("application/xaml+xml", null, "*.xaml", 1), + ["*.xap"] = new ContentTypeMapping("application/x-silverlight-app", null, "*.xap", 1), + ["*.xbap"] = new ContentTypeMapping("application/x-ms-xbap", null, "*.xbap", 1), + ["*.xht"] = new ContentTypeMapping("application/xhtml+xml", null, "*.xht", 1), + ["*.xhtml"] = new ContentTypeMapping("application/xhtml+xml", null, "*.xhtml", 1), + ["*.xla"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xla", 1), + ["*.xlam"] = new ContentTypeMapping("application/vnd.ms-excel.addin.macroEnabled.12", null, "*.xlam", 1), + ["*.xlc"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlc", 1), + ["*.xlm"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlm", 1), + ["*.xls"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xls", 1), + ["*.xlsb"] = new ContentTypeMapping("application/vnd.ms-excel.sheet.binary.macroEnabled.12", null, "*.xlsb", 1), + ["*.xlsm"] = new ContentTypeMapping("application/vnd.ms-excel.sheet.macroEnabled.12", null, "*.xlsm", 1), + ["*.xlsx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", null, "*.xlsx", 1), + ["*.xlt"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlt", 1), + ["*.xltm"] = new ContentTypeMapping("application/vnd.ms-excel.template.macroEnabled.12", null, "*.xltm", 1), + ["*.xltx"] = new ContentTypeMapping("application/vnd.openxmlformats-officedocument.spreadsheetml.template", null, "*.xltx", 1), + ["*.xlw"] = new ContentTypeMapping("application/vnd.ms-excel", null, "*.xlw", 1), + ["*.xof"] = new ContentTypeMapping("x-world/x-vrml", null, "*.xof", 1), + ["*.xps"] = new ContentTypeMapping("application/vnd.ms-xpsdocument", null, "*.xps", 1), + ["*.z"] = new ContentTypeMapping("application/x-compress", null, "*.z", 1), + ["*.zip"] = new ContentTypeMapping("application/x-zip-compressed", null, "*.zip", 1), + ["*.aaf"] = new ContentTypeMapping("application/octet-stream", null, "*.aaf", 1), + ["*.aca"] = new ContentTypeMapping("application/octet-stream", null, "*.aca", 1), + ["*.afm"] = new ContentTypeMapping("application/octet-stream", null, "*.afm", 1), + ["*.asd"] = new ContentTypeMapping("application/octet-stream", null, "*.asd", 1), + ["*.asi"] = new ContentTypeMapping("application/octet-stream", null, "*.asi", 1), + ["*.bin"] = new ContentTypeMapping("application/octet-stream", null, "*.bin", 1), + ["*.chm"] = new ContentTypeMapping("application/octet-stream", null, "*.chm", 1), + ["*.cur"] = new ContentTypeMapping("application/octet-stream", null, "*.cur", 1), + ["*.deploy"] = new ContentTypeMapping("application/octet-stream", null, "*.deploy", 1), + ["*.dsp"] = new ContentTypeMapping("application/octet-stream", null, "*.dsp", 1), + ["*.dwp"] = new ContentTypeMapping("application/octet-stream", null, "*.dwp", 1), + ["*.emz"] = new ContentTypeMapping("application/octet-stream", null, "*.emz", 1), + ["*.fla"] = new ContentTypeMapping("application/octet-stream", null, "*.fla", 1), + ["*.hhk"] = new ContentTypeMapping("application/octet-stream", null, "*.hhk", 1), + ["*.hhp"] = new ContentTypeMapping("application/octet-stream", null, "*.hhp", 1), + ["*.inf"] = new ContentTypeMapping("application/octet-stream", null, "*.inf", 1), + ["*.java"] = new ContentTypeMapping("application/octet-stream", null, "*.java", 1), + ["*.jpb"] = new ContentTypeMapping("application/octet-stream", null, "*.jpb", 1), + ["*.lpk"] = new ContentTypeMapping("application/octet-stream", null, "*.lpk", 1), + ["*.lzh"] = new ContentTypeMapping("application/octet-stream", null, "*.lzh", 1), + ["*.mdp"] = new ContentTypeMapping("application/octet-stream", null, "*.mdp", 1), + ["*.mix"] = new ContentTypeMapping("application/octet-stream", null, "*.mix", 1), + ["*.msi"] = new ContentTypeMapping("application/octet-stream", null, "*.msi", 1), + ["*.mso"] = new ContentTypeMapping("application/octet-stream", null, "*.mso", 1), + ["*.ocx"] = new ContentTypeMapping("application/octet-stream", null, "*.ocx", 1), + ["*.pcx"] = new ContentTypeMapping("application/octet-stream", null, "*.pcx", 1), + ["*.pcz"] = new ContentTypeMapping("application/octet-stream", null, "*.pcz", 1), + ["*.pfb"] = new ContentTypeMapping("application/octet-stream", null, "*.pfb", 1), + ["*.pfm"] = new ContentTypeMapping("application/octet-stream", null, "*.pfm", 1), + ["*.prm"] = new ContentTypeMapping("application/octet-stream", null, "*.prm", 1), + ["*.prx"] = new ContentTypeMapping("application/octet-stream", null, "*.prx", 1), + ["*.psd"] = new ContentTypeMapping("application/octet-stream", null, "*.psd", 1), + ["*.psm"] = new ContentTypeMapping("application/octet-stream", null, "*.psm", 1), + ["*.psp"] = new ContentTypeMapping("application/octet-stream", null, "*.psp", 1), + ["*.qxd"] = new ContentTypeMapping("application/octet-stream", null, "*.qxd", 1), + ["*.rar"] = new ContentTypeMapping("application/octet-stream", null, "*.rar", 1), + ["*.sea"] = new ContentTypeMapping("application/octet-stream", null, "*.sea", 1), + ["*.smi"] = new ContentTypeMapping("application/octet-stream", null, "*.smi", 1), + ["*.snp"] = new ContentTypeMapping("application/octet-stream", null, "*.snp", 1), + ["*.thn"] = new ContentTypeMapping("application/octet-stream", null, "*.thn", 1), + ["*.toc"] = new ContentTypeMapping("application/octet-stream", null, "*.toc", 1), + ["*.u32"] = new ContentTypeMapping("application/octet-stream", null, "*.u32", 1), + ["*.xsn"] = new ContentTypeMapping("application/octet-stream", null, "*.xsn", 1), + ["*.xtp"] = new ContentTypeMapping("application/octet-stream", null, "*.xtp", 1), }; - internal ContentTypeMapping ResolveContentTypeMapping(string relativePath, TaskLoggingHelper log) + private readonly StaticWebAssetGlobMatcher _matcher; + + private readonly Dictionary _customMappings; + + public ContentTypeProvider(ContentTypeMapping[] customMappings) { + _customMappings ??= []; foreach (var mapping in customMappings) { - if (mapping.Matches(Path.GetFileName(relativePath))) + _customMappings[mapping.Pattern] = mapping; + } + + _matcher = new StaticWebAssetGlobMatcherBuilder() + .AddIncludePatternsList(_builtInMappings.Keys) + .AddIncludePatternsList(_customMappings.Keys) + .Build(); + } + + // First we strip any compressed extension (e.g. .gz, .br) from the file name + // and then we try to match the file name with the existing mappings. + // If we don't find a match, we fallback to trying the entire file name. + internal ContentTypeMapping ResolveContentTypeMapping(StaticWebAssetGlobMatcher.MatchContext context, TaskLoggingHelper log) + { +#if NET9_0_OR_GREATER + var relativePath = context.Path; + var fileNameSpan = Path.GetFileName(context.Path); + var fileName = relativePath[(relativePath.Length - fileNameSpan.Length)..]; +#else + var relativePath = context.PathString; + var fileName = Path.GetFileName(relativePath); +#endif + var fileNameNoCompressionExt = ResolvePathWithoutCompressedExtension(fileName, out var hasCompressedExtension); + + context.SetPathAndReinitialize(fileNameNoCompressionExt); + if (TryGetMapping(context, log, relativePath, out var mapping)) + { + return mapping; + } + else if (hasCompressedExtension) + { + context.SetPathAndReinitialize(fileName); + if (hasCompressedExtension && TryGetMapping(context, log, relativePath, out mapping)) { - // If a custom mapping matches, it wins over the built-in - log.LogMessage(MessageImportance.Low, $"Matched {relativePath} to {mapping.MimeType} using pattern {mapping.Pattern}"); return mapping; } + } + + return default; + } + +#if NET9_0_OR_GREATER + private bool TryGetMapping(StaticWebAssetGlobMatcher.MatchContext context, TaskLoggingHelper log, ReadOnlySpan relativePath, out ContentTypeMapping mapping) +#else + private bool TryGetMapping(StaticWebAssetGlobMatcher.MatchContext context, TaskLoggingHelper log, string relativePath, out ContentTypeMapping mapping) +#endif + { + var match = _matcher.Match(context); + if (match.IsMatch) + { + if (_builtInMappings.TryGetValue(match.Pattern, out mapping) || _customMappings.TryGetValue(match.Pattern, out mapping)) + { + log.LogMessage(MessageImportance.Low, $"Matched {relativePath} to {mapping.MimeType} using pattern {match.Pattern}"); + return true; + } else { - log.LogMessage(MessageImportance.Low, $"No match for {relativePath} using pattern {mapping.Pattern}"); + throw new InvalidOperationException("Matched pattern but no mapping found."); } } - return ResolveBuiltIn(relativePath, log); + mapping = default; + return false; } - private static ContentTypeMapping ResolveBuiltIn(string relativePath, TaskLoggingHelper log) +#if NET9_0_OR_GREATER + private static ReadOnlySpan ResolvePathWithoutCompressedExtension(ReadOnlySpan fileName, out bool hasCompressedExtension) +#else + private static string ResolvePathWithoutCompressedExtension(string fileName, out bool hasCompressedExtension) +#endif { - var extension = Path.GetExtension(relativePath); - if (extension == ".gz" || extension == ".br") + var extension = Path.GetExtension(fileName); + hasCompressedExtension = extension.Equals(".gz", StringComparison.OrdinalIgnoreCase) || extension.Equals(".br", StringComparison.OrdinalIgnoreCase); + if (hasCompressedExtension) { - var fileName = Path.GetFileNameWithoutExtension(relativePath); - if (Path.GetExtension(fileName) != "") + var fileNameNoExtension = Path.GetFileNameWithoutExtension(fileName); + if (!Path.GetExtension(fileNameNoExtension).Equals("", StringComparison.Ordinal)) { - var result = ResolveBuiltIn(fileName, log); - // If we don't have a specific mapping for the other extension, use any mapping available for `.gz` or `.br` - return result.MimeType == null && _builtInMappings.TryGetValue(extension, out var compressed) ? - compressed : - result; +#if NET9_0_OR_GREATER + return fileName[..fileNameNoExtension.Length]; +#else + return fileName.Substring(0, fileNameNoExtension.Length); +#endif } } - return _builtInMappings.TryGetValue(extension, out var mapping) ? mapping : default; + return fileName; } } diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs index 160bd99d37d9..d4da92680f74 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs @@ -918,7 +918,7 @@ internal string EmbedTokens(string relativePath) var pattern = StaticWebAssetPathPattern.Parse(relativePath, Identity); var resolver = StaticWebAssetTokenResolver.Instance; pattern.EmbedTokens(this, resolver); - return pattern.RawPattern; + return pattern.RawPattern.ToString(); } internal FileInfo ResolveFile() => ResolveFile(Identity, OriginalItemSpec); diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs index b49a03ab66bc..dc7e855b0126 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs @@ -5,15 +5,25 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; -#if WASM_TASKS [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0057:Use range operator", Justification = "Can't use range syntax in full framework")] +#if WASM_TASKS internal sealed class StaticWebAssetPathPattern : IEquatable #else -[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed class StaticWebAssetPathPattern : IEquatable #endif { - public StaticWebAssetPathPattern(string path) => RawPattern = path; + private const string PatternStart = "#["; + private const char PatternEnd = ']'; + private const char PatternOptional = '?'; + private const char PatternPreferred = '!'; + private const char PatternValueSeparator = '='; + private const char PatternParameterStart = '{'; + private const char PatternParameterEnd = '}'; + + public StaticWebAssetPathPattern(string path) : this(path.AsMemory()) { } + + public StaticWebAssetPathPattern(ReadOnlyMemory rawPathMemory) => RawPattern = rawPathMemory; public StaticWebAssetPathPattern(List segments) { @@ -21,7 +31,7 @@ public StaticWebAssetPathPattern(List segments) Segments = segments; } - public string RawPattern { get; private set; } + public ReadOnlyMemory RawPattern { get; private set; } public IList Segments { get; set; } = []; @@ -56,14 +66,15 @@ public StaticWebAssetPathPattern(List segments) // to be embedded in other contexts. // We might include other tokens in the future, like `[{basepath}]` to give a file the ability to have its path be relative to the consuming // project base path, etc. - public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdentity = null) + public static StaticWebAssetPathPattern Parse(ReadOnlyMemory rawPathMemory, string assetIdentity = null) { - var pattern = new StaticWebAssetPathPattern(rawPath); - var nextToken = rawPath.IndexOf("#[", StringComparison.OrdinalIgnoreCase); + var pattern = new StaticWebAssetPathPattern(rawPathMemory); + var current = rawPathMemory; + var nextToken = MemoryExtensions.IndexOf(current.Span, PatternStart.AsSpan(), StringComparison.OrdinalIgnoreCase); if (nextToken == -1) { var literalSegment = new StaticWebAssetPathSegment(); - literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = rawPath, IsLiteral = true }); + literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current, IsLiteral = true }); pattern.Segments.Add(literalSegment); return pattern; } @@ -71,54 +82,58 @@ public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdenti if (nextToken > 0) { var literalSegment = new StaticWebAssetPathSegment(); -#if NET9_0_OR_GREATER - literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = rawPath[..nextToken], IsLiteral = true }); -#else - literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = rawPath.Substring(0, nextToken), IsLiteral = true }); -#endif + literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true }); pattern.Segments.Add(literalSegment); } + while (nextToken != -1) { - var tokenEnd = rawPath.IndexOf(']', nextToken); + current = current.Slice(nextToken); + var tokenEnd = MemoryExtensions.IndexOf(current.Span, PatternEnd); if (tokenEnd == -1) { if (assetIdentity != null) { // We don't have a closing token, this is likely an error, so throw - throw new InvalidOperationException($"Invalid relative path '{rawPath}' for asset '{assetIdentity}'. Missing ']' token."); + throw new InvalidOperationException($"Invalid relative path '{rawPathMemory}' for asset '{assetIdentity}'. Missing ']' token."); } else { - throw new InvalidOperationException($"Invalid token expression '{rawPath}'. Missing ']' token."); + throw new InvalidOperationException($"Invalid token expression '{rawPathMemory}'. Missing ']' token."); } } - var tokenExpression = rawPath.Substring(nextToken + 2, tokenEnd - nextToken - 2); + var tokenExpression = current.Slice(2, tokenEnd - 2); var token = new StaticWebAssetPathSegment(); AddTokenSegmentParts(tokenExpression, token); pattern.Segments.Add(token); // Check if the segment is optional (ends with ? or !) - if (tokenEnd < rawPath.Length - 1 && (rawPath[tokenEnd + 1] == '?' || rawPath[tokenEnd + 1] == '!')) + if (tokenEnd < current.Length - 1 && + (current.Span[tokenEnd + 1] == PatternOptional || current.Span[tokenEnd + 1] == PatternPreferred)) { token.IsOptional = true; - if (rawPath[tokenEnd + 1] == '!') + if (current.Span[tokenEnd + 1] == PatternPreferred) { token.IsPreferred = true; } tokenEnd++; } - nextToken = rawPath.IndexOf("#[", tokenEnd, comparisonType: StringComparison.OrdinalIgnoreCase); + current = current.Slice(tokenEnd + 1); + nextToken = MemoryExtensions.IndexOf(current.Span, PatternStart.AsSpan(), StringComparison.OrdinalIgnoreCase); - // Add a literal segment if there is more content after the token and before the next one - if ((nextToken != -1 && nextToken > tokenEnd + 1) || (nextToken == -1 && tokenEnd < rawPath.Length - 1)) + if (nextToken == -1 && current.Length > 0) + { + var literalSegment = new StaticWebAssetPathSegment(); + literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current, IsLiteral = true }); + pattern.Segments.Add(literalSegment); + } + else if (nextToken > 0) { - var literalEnd = nextToken == -1 ? rawPath.Length : nextToken; var literalSegment = new StaticWebAssetPathSegment(); - literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = rawPath.Substring(tokenEnd + 1, literalEnd - tokenEnd - 1), IsLiteral = true }); + literalSegment.Parts.Add(new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true }); pattern.Segments.Add(literalSegment); } } @@ -126,6 +141,66 @@ public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdenti return pattern; } + // Iterate over the token expression and add the parts to the token segment + // Some examples are '.{fingerprint}', '{fingerprint}.', '{fingerprint}{fingerprint}', {fingerprint}.{fingerprint} + // The '.' represents sample literal content. + // The value within the {} represents token variables. + private static void AddTokenSegmentParts(ReadOnlyMemory tokenExpression, StaticWebAssetPathSegment token) + { + var current = tokenExpression; + var nextToken = MemoryExtensions.IndexOf(current.Span, PatternParameterStart); + if (nextToken is not (-1) and > 0) + { + var literalPart = new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true }; + token.Parts.Add(literalPart); + } + + while (nextToken != -1) + { + current = current.Slice(nextToken); + var tokenEnd = MemoryExtensions.IndexOf(current.Span, PatternParameterEnd); + if (tokenEnd == -1) + { + throw new InvalidOperationException($"Invalid token expression '{tokenExpression}'. Missing '}}' token."); + } + + var embeddedValue = MemoryExtensions.IndexOf(current.Span, PatternValueSeparator); + if (embeddedValue != -1) + { + var tokenPart = new StaticWebAssetSegmentPart + { + Name = current.Slice(1, embeddedValue - 1), + IsLiteral = false, + Value = current.Slice(embeddedValue + 1, tokenEnd - embeddedValue - 1) + }; + token.Parts.Add(tokenPart); + } + else + { + var tokenPart = new StaticWebAssetSegmentPart { Name = current.Slice(1, tokenEnd - 1), IsLiteral = false }; + token.Parts.Add(tokenPart); + } + + current = current.Slice(tokenEnd + 1); + nextToken = MemoryExtensions.IndexOf(current.Span, PatternParameterStart); + if (nextToken == -1 && current.Length > 0) + { + var literalPart = new StaticWebAssetSegmentPart { Name = current, IsLiteral = true }; + token.Parts.Add(literalPart); + } + else if (nextToken > 0) + { + var literalPart = new StaticWebAssetSegmentPart { Name = current.Slice(0, nextToken), IsLiteral = true }; + token.Parts.Add(literalPart); + } + } + } + + public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdentity = null) + { + return Parse(rawPath.AsMemory(), assetIdentity); + } + // Replaces the tokens in the pattern with values provided in the expression, by the asset, or global resolvers. // Embedded values allow tasks to define the values that should be used when defining endpoints, while preserving the // original token information (for example, if its optional or if it should be preferred). @@ -163,14 +238,15 @@ public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdenti var missingValue = ""; foreach (var tokenName in tokenNames) { - if (!tokens.TryGetValue(staticWebAsset, tokenName, out var tokenValue) || string.IsNullOrEmpty(tokenValue)) + var tokenNameString = tokenName.ToString(); + if (!tokens.TryGetValue(staticWebAsset, tokenNameString, out var tokenValue) || string.IsNullOrEmpty(tokenValue)) { foundAllValues = false; - missingValue = tokenName; + missingValue = tokenNameString; break; } - dictionary[tokenName] = tokenValue; + dictionary[tokenNameString] = tokenValue; } if (!foundAllValues && !segment.IsOptional) @@ -192,15 +268,15 @@ public static StaticWebAssetPathPattern Parse(string rawPath, string assetIdenti { result.Append(part.Name); } - else if (!string.IsNullOrEmpty(part.Value)) + else if (!part.Value.IsEmpty) { // Token was embedded, so add it to the dictionary. - dictionary[part.Name] = part.Value; + dictionary[part.Name.ToString()] = part.Value.ToString(); result.Append(part.Value); } else { - result.Append(dictionary[part.Name]); + result.Append(dictionary[part.Name.ToString()]); } } } @@ -225,7 +301,6 @@ public IEnumerable ExpandPatternExpression() // - asset.css produces a single pattern (asset.css). // - other#[.{fingerprint}].js produces a single pattern asset#[.{fingerprint}].js // - last#[.{fingerprint}]?.txt produces two patterns last#[.{fingerprint}]?.txt and last.txt - var hasOptionalSegments = false; foreach (var segment in Segments) { @@ -340,14 +415,14 @@ internal void EmbedTokens(StaticWebAsset staticWebAsset, StaticWebAssetTokenReso continue; } - if (!resolver.TryGetValue(staticWebAsset, tokenName, out var tokenValue) || string.IsNullOrEmpty(tokenValue)) + if (!resolver.TryGetValue(staticWebAsset, tokenName.ToString(), out var tokenValue) || string.IsNullOrEmpty(tokenValue)) { continue; } - if (string.Equals(part.Name, tokenName)) + if (part.Name.Span.SequenceEqual(tokenName.Span)) { - part.Value = tokenValue; + part.Value = tokenValue.AsMemory(); } } } @@ -355,58 +430,7 @@ internal void EmbedTokens(StaticWebAsset staticWebAsset, StaticWebAssetTokenReso RawPattern = GetRawPattern(Segments); } - // Iterate over the token expression and add the parts to the token segment - // Some examples are '.{fingerprint}', '{fingerprint}.', '{fingerprint}{fingerprint}', {fingerprint}.{fingerprint} - // The '.' represents sample literal content. - // The value within the {} represents token variables. - private static void AddTokenSegmentParts(string tokenExpression, StaticWebAssetPathSegment token) - { - var nextToken = tokenExpression.IndexOf('{'); - if (nextToken is not (-1) and > 0) - { -#if NET9_0_OR_GREATER - var literalPart = new StaticWebAssetSegmentPart { Name = tokenExpression[..nextToken], IsLiteral = true }; -#else - var literalPart = new StaticWebAssetSegmentPart { Name = tokenExpression.Substring(0, nextToken), IsLiteral = true }; -#endif - token.Parts.Add(literalPart); - } - while (nextToken != -1) - { - var tokenEnd = tokenExpression.IndexOf('}', nextToken); - if (tokenEnd == -1) - { - throw new InvalidOperationException($"Invalid token expression '{tokenExpression}'. Missing '}}' token."); - } - - var embeddedValue = tokenExpression.IndexOf('=', nextToken); - if (embeddedValue != -1) - { - var tokenPart = new StaticWebAssetSegmentPart - { - Name = tokenExpression.Substring(nextToken + 1, embeddedValue - nextToken - 1), - IsLiteral = false, - Value = tokenExpression.Substring(embeddedValue + 1, tokenEnd - embeddedValue - 1) - }; - token.Parts.Add(tokenPart); - } - else - { - var tokenPart = new StaticWebAssetSegmentPart { Name = tokenExpression.Substring(nextToken + 1, tokenEnd - nextToken - 1), IsLiteral = false }; - token.Parts.Add(tokenPart); - } - - nextToken = tokenExpression.IndexOf('{', tokenEnd); - if ((nextToken != -1 && nextToken > tokenEnd + 1) || (nextToken == -1 && tokenEnd < tokenExpression.Length - 1)) - { - var literalEnd = nextToken == -1 ? tokenExpression.Length : nextToken; - var literalPart = new StaticWebAssetSegmentPart { Name = tokenExpression.Substring(tokenEnd + 1, literalEnd - tokenEnd - 1), IsLiteral = true }; - token.Parts.Add(literalPart); - } - } - } - - private static string GetRawPattern(IList segments) + private static ReadOnlyMemory GetRawPattern(IList segments) { var stringBuilder = new StringBuilder(); for (var i = 0; i < segments.Count; i++) @@ -415,42 +439,45 @@ private static string GetRawPattern(IList segments) var isLiteral = IsLiteralSegment(segment); if (!isLiteral) { - stringBuilder.Append("#["); + stringBuilder.Append(PatternStart); } for (var j = 0; j < segment.Parts.Count; j++) { var part = segment.Parts[j]; - stringBuilder.Append(part.IsLiteral ? part.Name : $$"""{{{(!string.IsNullOrEmpty(part.Value) ? $"""{part.Name}={part.Value}""" : part.Name)}}}"""); + stringBuilder.Append(part.IsLiteral ? part.Name : $$"""{{{(!part.Value.IsEmpty ? $"""{part.Name}{PatternValueSeparator}{part.Value}""" : part.Name)}}}"""); } if (!isLiteral) { - stringBuilder.Append(']'); + stringBuilder.Append(PatternEnd); if (segment.IsOptional) { if (segment.IsPreferred) { - stringBuilder.Append('!'); + stringBuilder.Append(PatternPreferred); } else { - stringBuilder.Append('?'); + stringBuilder.Append(PatternOptional); } } } } - return stringBuilder.ToString(); + return stringBuilder.ToString().AsMemory(); } public override bool Equals(object obj) => Equals(obj as StaticWebAssetPathPattern); - public bool Equals(StaticWebAssetPathPattern other) => other is not null && RawPattern == other.RawPattern && Segments.SequenceEqual(other.Segments); + public bool Equals(StaticWebAssetPathPattern other) => + other is not null && + MemoryExtensions.Equals(RawPattern.Span, other.RawPattern.Span, StringComparison.Ordinal) && + Segments.SequenceEqual(other.Segments); #if NET47_OR_GREATER public override int GetHashCode() { var hashCode = 1219904980; - hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(RawPattern); + hashCode = (hashCode * -1521134295) + EqualityComparer>.Default.GetHashCode(RawPattern); hashCode = (hashCode * -1521134295) + EqualityComparer>.Default.GetHashCode(Segments); return hashCode; } @@ -468,10 +495,12 @@ public override int GetHashCode() #endif public static bool operator ==(StaticWebAssetPathPattern left, StaticWebAssetPathPattern right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(StaticWebAssetPathPattern left, StaticWebAssetPathPattern right) => !(left == right); private string GetDebuggerDisplay() => string.Concat(Segments.Select(s => s.GetDebuggerDisplay())); private static bool IsLiteralSegment(StaticWebAssetPathSegment segment) => segment.Parts.Count == 1 && segment.Parts[0].IsLiteral; + internal static string PathWithoutTokens(string path) => Parse(path).ComputePatternLabel(); } diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathSegment.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathSegment.cs index a0075ebf765c..d41e4d685a0d 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathSegment.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathSegment.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; @@ -11,6 +11,7 @@ public class StaticWebAssetPathSegment : IEquatable public IList Parts { get; set; } = []; public bool IsOptional { get; set; } + public bool IsPreferred { get; set; } public override bool Equals(object obj) => Equals(obj as StaticWebAssetPathSegment); @@ -45,18 +46,18 @@ public override int GetHashCode() internal string GetDebuggerDisplay() { - return Parts != null && Parts.Count == 1 && Parts[0].IsLiteral ? Parts[0].Name : ComputeParameterExpression(); + return Parts != null && Parts.Count == 1 && Parts[0].IsLiteral ? Parts[0].Name.ToString() : ComputeParameterExpression(); string ComputeParameterExpression() => - string.Concat(Parts.Select(p => p.IsLiteral ? p.Name : $"{{{p.Name}}}").Prepend("#[").Append($"]{(IsOptional ? (IsPreferred ? "!" : "?") : "")}")); + string.Concat(Parts.Select(p => p.IsLiteral ? p.Name.ToString() : $"{{{p.Name}}}").Prepend("#[").Append($"]{(IsOptional ? (IsPreferred ? "!" : "?") : "")}")); } - internal ICollection GetTokenNames() + internal ICollection> GetTokenNames() { - var result = new HashSet(); + var result = new HashSet>(); foreach (var part in Parts) { - if (!part.IsLiteral && string.IsNullOrEmpty(part.Value)) + if (!part.IsLiteral && part.Name.Length > 0) { result.Add(part.Name); } diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetSegmentPart.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetSegmentPart.cs index 4a7acc06d011..44990bcce9b3 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetSegmentPart.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetSegmentPart.cs @@ -1,33 +1,53 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; + namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public class StaticWebAssetSegmentPart : IEquatable { - public string Name { get; set; } + public ReadOnlyMemory Name { get; set; } - public string Value { get; set; } + public ReadOnlyMemory Value { get; set; } public bool IsLiteral { get; set; } public override bool Equals(object obj) => Equals(obj as StaticWebAssetSegmentPart); - public bool Equals(StaticWebAssetSegmentPart other) => other is not null && Name == other.Name && Value == other.Value && IsLiteral == other.IsLiteral; + public bool Equals(StaticWebAssetSegmentPart other) => other is not null && + IsLiteral == other.IsLiteral && + Name.Span.SequenceEqual(other.Name.Span) && + Value.Span.SequenceEqual(other.Value.Span); #if NET47_OR_GREATER public override int GetHashCode() { var hashCode = -62096114; - hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(Name); - hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(Value); + hashCode = (hashCode * -1521134295) + GetSpanHashCode(Name); + hashCode = (hashCode * -1521134295) + GetSpanHashCode(Value); hashCode = (hashCode * -1521134295) + IsLiteral.GetHashCode(); return hashCode; } + + private int GetSpanHashCode(ReadOnlyMemory memory) + { + var hashCode = -62096114; + var span = memory.Span; + for ( var i = 0; i < span.Length; i++) + { + hashCode = (hashCode * -1521134295) + span[i].GetHashCode(); + } + + return hashCode; + } #else public override int GetHashCode() => HashCode.Combine(Name, Value, IsLiteral); #endif public static bool operator ==(StaticWebAssetSegmentPart left, StaticWebAssetSegmentPart right) => EqualityComparer.Default.Equals(left, right); public static bool operator !=(StaticWebAssetSegmentPart left, StaticWebAssetSegmentPart right) => !(left == right); + + private string GetDebuggerDisplay() => IsLiteral ? Value.ToString() : $"{{{Name}}}"; } diff --git a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs index 475b2bdf9e88..16c263537a43 100644 --- a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs +++ b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs @@ -3,8 +3,8 @@ using System.Globalization; using Microsoft.Build.Framework; -using System.Collections.Concurrent; using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; +using Microsoft.Build.Utilities; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -43,41 +43,23 @@ public override bool Execute() var existingEndpointsByAssetFile = CreateEndpointsByAssetFile(); var contentTypeMappings = ContentTypeMappings.Select(ContentTypeMapping.FromTaskItem).OrderByDescending(m => m.Priority).ToArray(); var contentTypeProvider = new ContentTypeProvider(contentTypeMappings); - var endpoints = new ConcurrentBag(); - - Parallel.For(0, CandidateAssets.Length, i => - { - var asset = StaticWebAsset.FromTaskItem(CandidateAssets[i]); - var routes = asset.ComputeRoutes().ToList(); - - if (existingEndpointsByAssetFile != null && existingEndpointsByAssetFile.TryGetValue(asset.Identity, out var set)) - { - for (var j = routes.Count - 1; j >= 0; j--) - { - var (label, route, values) = routes[j]; - // StaticWebAssets has this behavior where the base path for an asset only gets applied if the asset comes from a - // package or a referenced project and ignored if it comes from the current project. - // When we define the endpoint, we apply the path to the asset as if it was coming from the current project. - // If the endpoint is then passed to a referencing project or packaged into a nuget package, the path will be - // adjusted at that time. - var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route; - - // Check if the endpoint we are about to define already exists. This can happen during publish as assets defined - // during the build will have already defined endpoints and we only want to add new ones. - if (set.Contains(finalRoute)) - { - Log.LogMessage(MessageImportance.Low, $"Skipping asset {asset.Identity} because an endpoint for it already exists at {route}."); - routes.RemoveAt(j); - } - } - } - - foreach (var endpoint in CreateEndpoints(routes, asset, contentTypeProvider)) - { - Log.LogMessage(MessageImportance.Low, $"Adding endpoint {endpoint.Route} for asset {asset.Identity}."); - endpoints.Add(endpoint); - } - }); + var endpoints = new List(); + + Parallel.For( + 0, + CandidateAssets.Length, + () => new ParallelWorker( + endpoints, + new List(), + CandidateAssets, + existingEndpointsByAssetFile, + Log, + contentTypeProvider, + _assetFileDetails, + TestLengthResolver, + TestLastWriteResolver), + static (i, loop, state) => state.Process(i, loop), + static worker => worker.Finally()); Endpoints = StaticWebAssetEndpoint.ToTaskItems(endpoints); @@ -121,15 +103,39 @@ private Dictionary> CreateEndpointsByAssetFile() return null; } - private List CreateEndpoints(List routes, StaticWebAsset asset, ContentTypeProvider contentTypeMappings) + private readonly struct ParallelWorker( + List collectedEndpoints, + List currentEndpoints, + ITaskItem[] candidateAssets, + Dictionary> existingEndpointsByAssetFile, + TaskLoggingHelper log, + ContentTypeProvider contentTypeProvider, + Dictionary assetDetails, + Func testLengthResolver, + Func testLastWriteResolver) { - var (length, lastModified) = ResolveDetails(asset); - var result = new List(); - foreach (var (label, route, values) in routes) + public List CollectedEndpoints { get; } = collectedEndpoints; + public List CurrentEndpoints { get; } = currentEndpoints; + public ITaskItem[] CandidateAssets { get; } = candidateAssets; + public Dictionary> ExistingEndpointsByAssetFile { get; } = existingEndpointsByAssetFile; + public TaskLoggingHelper Log { get; } = log; + public ContentTypeProvider ContentTypeProvider { get; } = contentTypeProvider; + public Dictionary AssetDetails { get; } = assetDetails; + public Func TestLengthResolver { get; } = testLengthResolver; + public Func TestLastWriteResolver { get; } = testLastWriteResolver; + + private List CreateEndpoints( + List routes, + StaticWebAsset asset, + StaticWebAssetGlobMatcher.MatchContext matchContext) { - var (mimeType, cacheSetting) = ResolveContentType(asset, contentTypeMappings); - List headers = [ - new() + var (length, lastModified) = ResolveDetails(asset); + var result = new List(); + foreach (var (label, route, values) in routes) + { + var (mimeType, cacheSetting) = ResolveContentType(asset, ContentTypeProvider, matchContext, Log); + List headers = [ + new() { Name = "Accept-Ranges", Value = "bytes" @@ -156,129 +162,177 @@ private List CreateEndpoints(List new StaticWebAssetEndpointProperty { Name = v.Key, Value = v.Value }); + if (values.Count > 0) + { + // If an endpoint has values from its route replaced, we add a label to the endpoint so that it can be easily identified. + // The combination of label and list of values should be unique. + // In this way, we can identify an endpoint resource.fingerprint.ext by its label (for example resource.ext) and its values + // (fingerprint). + properties = properties.Append(new StaticWebAssetEndpointProperty { Name = "label", Value = label }); + } + + // We append the integrity in the format expected by the browser so that it can be opaque to the runtime. + // If in the future we change it to sha384 or sha512, the runtime will not need to be updated. + properties = properties.Append(new StaticWebAssetEndpointProperty { Name = "integrity", Value = $"sha256-{asset.Integrity}" }); + + var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route; + + var endpoint = new StaticWebAssetEndpoint() + { + Route = finalRoute, + AssetFile = asset.Identity, + EndpointProperties = [.. properties], + ResponseHeaders = [.. headers] + }; + result.Add(endpoint); + } + + return result; + } + + // Last-Modified: , :: GMT + // Directives + // + // One of "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", or "Sun" (case-sensitive). + // + // + // 2 digit day number, e.g. "04" or "23". + // + // + // One of "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" (case sensitive). + // + // + // 4 digit year number, e.g. "1990" or "2016". + // + // + // 2 digit hour number, e.g. "09" or "23". + // + // + // 2 digit minute number, e.g. "04" or "59". + // + // + // 2 digit second number, e.g. "04" or "59". + // + // GMT + // Greenwich Mean Time.HTTP dates are always expressed in GMT, never in local time. + private (string length, string lastModified) ResolveDetails(StaticWebAsset asset) + { + if (AssetDetails != null && AssetDetails.TryGetValue(asset.Identity, out var details)) { - // max-age=31536000 is one year in seconds. immutable means that the asset will never change. - // max-age is for browsers that do not support immutable. - headers.Add(new() { Name = "Cache-Control", Value = "max-age=31536000, immutable" }); + return (length: details.GetMetadata("FileLength"), lastModified: details.GetMetadata("LastWriteTimeUtc")); } - else + else if (AssetDetails != null && AssetDetails.TryGetValue(asset.OriginalItemSpec, out var originalDetails)) { - // Force revalidation on non-fingerprinted assets. We can be more granular here and have rules based on the content type. - // These values can later be changed at runtime by modifying the endpoint. For example, it might be safer to cache images - // for a longer period of time than scripts or stylesheets. - headers.Add(new() { Name = "Cache-Control", Value = !string.IsNullOrEmpty(cacheSetting) ? cacheSetting : "no-cache" }); + return (length: originalDetails.GetMetadata("FileLength"), lastModified: originalDetails.GetMetadata("LastWriteTimeUtc")); } - - var properties = values.Select(v => new StaticWebAssetEndpointProperty { Name = v.Key, Value = v.Value }); - if (values.Count > 0) + else if (TestLastWriteResolver != null || TestLengthResolver != null) { - // If an endpoint has values from its route replaced, we add a label to the endpoint so that it can be easily identified. - // The combination of label and list of values should be unique. - // In this way, we can identify an endpoint resource.fingerprint.ext by its label (for example resource.ext) and its values - // (fingerprint). - properties = properties.Append(new StaticWebAssetEndpointProperty { Name = "label", Value = label }); + return (length: GetTestFileLength(asset), lastModified: GetTestFileLastModified(asset)); } - - // We append the integrity in the format expected by the browser so that it can be opaque to the runtime. - // If in the future we change it to sha384 or sha512, the runtime will not need to be updated. - properties = properties.Append(new StaticWebAssetEndpointProperty { Name = "integrity", Value = $"sha256-{asset.Integrity}" }); - - var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route; - - var endpoint = new StaticWebAssetEndpoint() + else { - Route = finalRoute, - AssetFile = asset.Identity, - EndpointProperties = [.. properties], - ResponseHeaders = [.. headers] - }; - result.Add(endpoint); + Log.LogMessage(MessageImportance.Normal, $"No details found for {asset.Identity}. Using file system to resolve details."); + var fileInfo = StaticWebAsset.ResolveFile(asset.Identity, asset.OriginalItemSpec); + var length = fileInfo.Length.ToString(CultureInfo.InvariantCulture); + var lastModified = fileInfo.LastWriteTimeUtc.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture); + return (length, lastModified); + } } - return result; - } - - // Last-Modified: , :: GMT - // Directives - // - // One of "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", or "Sun" (case-sensitive). - // - // - // 2 digit day number, e.g. "04" or "23". - // - // - // One of "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" (case sensitive). - // - // - // 4 digit year number, e.g. "1990" or "2016". - // - // - // 2 digit hour number, e.g. "09" or "23". - // - // - // 2 digit minute number, e.g. "04" or "59". - // - // - // 2 digit second number, e.g. "04" or "59". - // - // GMT - // Greenwich Mean Time.HTTP dates are always expressed in GMT, never in local time. - private (string length, string lastModified) ResolveDetails(StaticWebAsset asset) - { - if (_assetFileDetails != null && _assetFileDetails.TryGetValue(asset.Identity, out var details)) + // Only used for testing + private string GetTestFileLastModified(StaticWebAsset asset) { - return (length: details.GetMetadata("FileLength"), lastModified: details.GetMetadata("LastWriteTimeUtc")); + var lastWrite = TestLastWriteResolver != null ? TestLastWriteResolver(asset.Identity) : asset.ResolveFile().LastWriteTimeUtc; + return lastWrite.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture); } - else if (_assetFileDetails != null && _assetFileDetails.TryGetValue(asset.OriginalItemSpec, out var originalDetails)) - { - return (length: originalDetails.GetMetadata("FileLength"), lastModified: originalDetails.GetMetadata("LastWriteTimeUtc")); - } - else if (TestLastWriteResolver != null || TestLengthResolver != null) + + // Only used for testing + private string GetTestFileLength(StaticWebAsset asset) { - return (length: GetTestFileLength(asset), lastModified: GetTestFileLastModified(asset)); + if (TestLengthResolver != null) + { + return TestLengthResolver(asset.Identity).ToString(CultureInfo.InvariantCulture); + } + + var fileInfo = asset.ResolveFile(); + return fileInfo.Length.ToString(CultureInfo.InvariantCulture); } - else + + private static (string mimeType, string cache) ResolveContentType(StaticWebAsset asset, ContentTypeProvider contentTypeProvider, StaticWebAssetGlobMatcher.MatchContext matchContext, TaskLoggingHelper log) { - Log.LogMessage(MessageImportance.High, $"No details found for {asset.Identity}. Using file system to resolve details."); - var fileInfo = StaticWebAsset.ResolveFile(asset.Identity, asset.OriginalItemSpec); - var length = fileInfo.Length.ToString(CultureInfo.InvariantCulture); - var lastModified = fileInfo.LastWriteTimeUtc.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture); - return (length, lastModified); - } - } + var relativePath = asset.ComputePathWithoutTokens(asset.RelativePath); + matchContext.SetPathAndReinitialize(relativePath); - // Only used for testing - private string GetTestFileLastModified(StaticWebAsset asset) - { - var lastWrite = TestLastWriteResolver != null ? TestLastWriteResolver(asset.Identity) : asset.ResolveFile().LastWriteTimeUtc; - return lastWrite.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture); - } + var mapping = contentTypeProvider.ResolveContentTypeMapping(matchContext, log); - // Only used for testing - private string GetTestFileLength(StaticWebAsset asset) - { - if (TestLengthResolver != null) - { - return TestLengthResolver(asset.Identity).ToString(CultureInfo.InvariantCulture); - } + if (mapping.MimeType != null) + { + return (mapping.MimeType, mapping.Cache); + } - var fileInfo = asset.ResolveFile(); - return fileInfo.Length.ToString(CultureInfo.InvariantCulture); - } + log.LogMessage(MessageImportance.Low, $"No match for {relativePath}. Using default content type 'application/octet-stream'"); - private (string mimeType, string cache) ResolveContentType(StaticWebAsset asset, ContentTypeProvider contentTypeProvider) - { - var relativePath = asset.ComputePathWithoutTokens(asset.RelativePath); - var mapping = contentTypeProvider.ResolveContentTypeMapping(relativePath, Log); + return ("application/octet-stream", null); + } - if (mapping.MimeType != null) + internal void Finally() { - return (mapping.MimeType, mapping.Cache); + lock (CollectedEndpoints) + { + CollectedEndpoints.AddRange(CurrentEndpoints); + } } - Log.LogMessage(MessageImportance.Low, $"No match for {relativePath}. Using default content type 'application/octet-stream'"); + internal ParallelWorker Process(int i, ParallelLoopState _) + { + var asset = StaticWebAsset.FromTaskItem(CandidateAssets[i]); + var routes = asset.ComputeRoutes().ToList(); + var matchContext = StaticWebAssetGlobMatcher.CreateMatchContext(); + + if (ExistingEndpointsByAssetFile != null && ExistingEndpointsByAssetFile.TryGetValue(asset.Identity, out var set)) + { + for (var j = routes.Count - 1; j >= 0; j--) + { + var (_, route, _) = routes[j]; + // StaticWebAssets has this behavior where the base path for an asset only gets applied if the asset comes from a + // package or a referenced project and ignored if it comes from the current project. + // When we define the endpoint, we apply the path to the asset as if it was coming from the current project. + // If the endpoint is then passed to a referencing project or packaged into a nuget package, the path will be + // adjusted at that time. + var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route; + + // Check if the endpoint we are about to define already exists. This can happen during publish as assets defined + // during the build will have already defined endpoints and we only want to add new ones. + if (set.Contains(finalRoute)) + { + Log.LogMessage(MessageImportance.Low, $"Skipping asset {asset.Identity} because an endpoint for it already exists at {route}."); + routes.RemoveAt(j); + } + } + } + + foreach (var endpoint in CreateEndpoints(routes, asset, matchContext)) + { + Log.LogMessage(MessageImportance.Low, $"Adding endpoint {endpoint.Route} for asset {asset.Identity}."); + CurrentEndpoints.Add(endpoint); + } - return ("application/octet-stream", null); + return this; + } } } diff --git a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs index 52f8583ea0fa..499460f002c7 100644 --- a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.Extensions.FileSystemGlobbing; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -23,8 +22,6 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; // path of the assets and so on. public class DefineStaticWebAssets : Task { - private const string DefaultFingerprintExpression = "#[.{fingerprint}]?"; - [Required] public ITaskItem[] CandidateAssets { get; set; } @@ -83,17 +80,17 @@ public override bool Execute() var copyCandidates = new List(); var assetDetails = new List(); - var matcher = !string.IsNullOrEmpty(RelativePathPattern) ? new Matcher().AddInclude(RelativePathPattern) : null; - var filter = !string.IsNullOrEmpty(RelativePathFilter) ? new Matcher().AddInclude(RelativePathFilter) : null; - var assetsByRelativePath = new Dictionary>(); - var fingerprintPatterns = (FingerprintPatterns ?? []).Select(p => new FingerprintPattern(p)).ToArray(); -#if NET9_0_OR_GREATER - var tokensByPattern = fingerprintPatterns.Where(p => !string.IsNullOrEmpty(p.Expression)).ToDictionary(p => p.Pattern[1..], p => p.Expression); -#else - var tokensByPattern = fingerprintPatterns.Where(p => !string.IsNullOrEmpty(p.Expression)).ToDictionary(p => p.Pattern.Substring(1), p => p.Expression); -#endif - Array.Sort(fingerprintPatterns, (a, b) => a.Pattern.Count(c => c == '.').CompareTo(b.Pattern.Count(c => c == '.'))); + var matcher = !string.IsNullOrEmpty(RelativePathPattern) ? + new StaticWebAssetGlobMatcherBuilder().AddIncludePatterns(RelativePathPattern).Build() : + null; + var filter = !string.IsNullOrEmpty(RelativePathFilter) ? + new StaticWebAssetGlobMatcherBuilder().AddIncludePatterns(RelativePathFilter).Build() : + null; + + var assetsByRelativePath = new Dictionary>(); + var fingerprintPatternMatcher = new FingerprintPatternMatcher(Log, FingerprintCandidates ? (FingerprintPatterns ?? []) : []); + var matchContext = StaticWebAssetGlobMatcher.CreateMatchContext(); for (var i = 0; i < CandidateAssets.Length; i++) { var candidate = CandidateAssets[i]; @@ -104,16 +101,17 @@ public override bool Execute() relativePathCandidate = candidateMatchPath; if (matcher != null && string.IsNullOrEmpty(candidate.GetMetadata("RelativePath"))) { - var match = matcher.Match(StaticWebAssetPathPattern.PathWithoutTokens(candidateMatchPath)); - if (!match.HasMatches) + matchContext.SetPathAndReinitialize(StaticWebAssetPathPattern.PathWithoutTokens(candidateMatchPath)); + var match = matcher.Match(matchContext); + if (!match.IsMatch) { Log.LogMessage(MessageImportance.Low, "Rejected asset '{0}' for pattern '{1}'", candidateMatchPath, RelativePathPattern); continue; } - Log.LogMessage(MessageImportance.Low, "Accepted asset '{0}' for pattern '{1}' with relative path '{2}'", candidateMatchPath, RelativePathPattern, match.Files.Single().Stem); + Log.LogMessage(MessageImportance.Low, "Accepted asset '{0}' for pattern '{1}' with relative path '{2}'", candidateMatchPath, RelativePathPattern, match.Stem); - relativePathCandidate = StaticWebAsset.Normalize(match.Files.Single().Stem); + relativePathCandidate = StaticWebAsset.Normalize(match.Stem); } } else @@ -121,10 +119,11 @@ public override bool Execute() relativePathCandidate = GetCandidateMatchPath(candidate); if (matcher != null) { - var match = matcher.Match(StaticWebAssetPathPattern.PathWithoutTokens(relativePathCandidate)); - if (match.HasMatches) + matchContext.SetPathAndReinitialize(StaticWebAssetPathPattern.PathWithoutTokens(relativePathCandidate)); + var match = matcher.Match(matchContext); + if (match.IsMatch) { - var newRelativePathCandidate = match.Files.Single().Stem; + var newRelativePathCandidate = match.Stem; Log.LogMessage( MessageImportance.Low, "The relative path '{0}' matched the pattern '{1}'. Replacing relative path with '{2}'.", @@ -136,16 +135,20 @@ public override bool Execute() } } - if (filter != null && !filter.Match(StaticWebAssetPathPattern.PathWithoutTokens(relativePathCandidate)).HasMatches) + if (filter != null) { - Log.LogMessage( - MessageImportance.Low, - "Skipping '{0}' because the relative path '{1}' did not match the filter '{2}'.", - candidate.ItemSpec, - relativePathCandidate, - RelativePathFilter); - - continue; + matchContext.SetPathAndReinitialize(StaticWebAssetPathPattern.PathWithoutTokens(relativePathCandidate)); + if (!filter.Match(matchContext).IsMatch) + { + Log.LogMessage( + MessageImportance.Low, + "Skipping '{0}' because the relative path '{1}' did not match the filter '{2}'.", + candidate.ItemSpec, + relativePathCandidate, + RelativePathFilter); + + continue; + } } } @@ -218,7 +221,7 @@ public override bool Execute() { // We ignore the content root for publish only assets since it doesn't matter. var contentRootPrefix = StaticWebAsset.AssetKinds.IsPublish(assetKind) ? null : contentRoot; - (identity, var computed) = ComputeCandidateIdentity(candidate, contentRootPrefix, relativePathCandidate, matcher); + (identity, var computed) = ComputeCandidateIdentity(candidate, contentRootPrefix, relativePathCandidate, matcher, matchContext); if (computed) { @@ -229,9 +232,11 @@ public override bool Execute() } } - relativePathCandidate = FingerprintCandidates ? - StaticWebAsset.Normalize(AppendFingerprintPattern(relativePathCandidate, identity, fingerprintPatterns, tokensByPattern)) : - relativePathCandidate; + if (FingerprintCandidates) + { + matchContext.SetPathAndReinitialize(relativePathCandidate); + relativePathCandidate = StaticWebAsset.Normalize(fingerprintPatternMatcher.AppendFingerprintPattern(matchContext, identity)); + } var asset = StaticWebAsset.FromProperties( identity, @@ -276,107 +281,12 @@ public override bool Execute() return !Log.HasLoggedErrors; } - private string AppendFingerprintPattern( - string relativePathCandidate, - string identity, - FingerprintPattern[] fingerprintPatterns, - Dictionary tokensByPattern) - { - if (relativePathCandidate.Contains("#[")) - { - var pattern = StaticWebAssetPathPattern.Parse(relativePathCandidate, identity); - foreach (var segment in pattern.Segments) - { - foreach (var part in segment.Parts) - { - foreach (var name in segment.GetTokenNames()) - { - if (string.Equals(name, "fingerprint", StringComparison.OrdinalIgnoreCase)) - { - return relativePathCandidate; - } - } - } - } - } - - // Fingerprinting patterns for content.By default(most common case), we check for a single extension, like.js or.css. - // In that situation we apply the fingerprint expression directly to the file name, like app.js->app#[.{fingerprint}].js. - // If we detect more than one extension, for example, Rcl.lib.module.js or Rcl.Razor.js, we retrieve the last extension and - // check for a mapping in the list below.If we find a match, we apply the fingerprint expression to the file name, like - // Rcl.lib.module.js->Rcl#[.{fingerprint}].lib.module.js. If we don't find a match, we add the extension to the name and - // continue matching against the next segment, like Rcl.Razor.js->Rcl.Razor#[.{fingerprint}].js. - // If we don't find a match, we apply the fingerprint before the first extension, like Rcl.Razor.js -> Rcl.Razor#[.{fingerprint}].js. - var directoryName = Path.GetDirectoryName(relativePathCandidate); - relativePathCandidate = Path.GetFileName(relativePathCandidate); - var extensionCount = 0; - var stem = relativePathCandidate; - var extension = Path.GetExtension(relativePathCandidate); - while (!string.IsNullOrEmpty(extension) || extensionCount < 2) - { - extensionCount++; -#if NET9_0_OR_GREATER - stem = stem[..^extension.Length]; -#else - stem = stem.Substring(0, stem.Length - extension.Length); -#endif - extension = Path.GetExtension(stem); - } - - // Simple case, single extension or no extension - // For example: - // app.js->app#[.{fingerprint}]?.js - // app->README#[.{fingerprint}]? - if (extensionCount < 2) - { - if (!tokensByPattern.TryGetValue(extension, out var expression)) - { - expression = DefaultFingerprintExpression; - } - - var simpleExtensionResult = Path.Combine(directoryName, $"{stem}{expression}{extension}"); - Log.LogMessage(MessageImportance.Low, "Fingerprinting asset '{0}' as '{1}'", relativePathCandidate, simpleExtensionResult); - return simpleExtensionResult; - } - - // Complex case, multiple extensions, try matching against known patterns - // For example: - // Rcl.lib.module.js->Rcl#[.{fingerprint}].lib.module.js - // Rcl.Razor.js->Rcl.Razor#[.{fingerprint}].js - foreach (var pattern in fingerprintPatterns) - { - var matchResult = pattern.Matcher.Match(StaticWebAssetPathPattern.PathWithoutTokens(relativePathCandidate)); - if (matchResult.HasMatches) - { -#if NET9_0_OR_GREATER - stem = relativePathCandidate[..(1 + relativePathCandidate.Length - pattern.Pattern.Length)]; - extension = relativePathCandidate[stem.Length..]; -#else - stem = relativePathCandidate.Substring(0, 1 + relativePathCandidate.Length - pattern.Pattern.Length); - extension = relativePathCandidate.Substring(stem.Length); -#endif - if (!tokensByPattern.TryGetValue(extension, out var expression)) - { - expression = DefaultFingerprintExpression; - } - var patternResult = Path.Combine(directoryName, $"{stem}{expression}{extension}"); - Log.LogMessage(MessageImportance.Low, "Fingerprinting asset '{0}' as '{1}' because it matched pattern '{2}'", relativePathCandidate, patternResult, pattern.Pattern); - return patternResult; - } - } - - // Multiple extensions and no match, apply the fingerprint before the first extension - // For example: - // Rcl.Razor.js->Rcl.Razor#[.{fingerprint}].js - stem = Path.GetFileNameWithoutExtension(relativePathCandidate); - extension = Path.GetExtension(relativePathCandidate); - var result = Path.Combine(directoryName, $"{stem}{DefaultFingerprintExpression}{extension}"); - Log.LogMessage(MessageImportance.Low, "Fingerprinting asset '{0}' as '{1}' because it didn't match any pattern", relativePathCandidate, result); - - return result; - } - - private (string identity, bool computed) ComputeCandidateIdentity(ITaskItem candidate, string contentRoot, string relativePath, Matcher matcher) + private (string identity, bool computed) ComputeCandidateIdentity( + ITaskItem candidate, + string contentRoot, + string relativePath, + StaticWebAssetGlobMatcher matcher, + StaticWebAssetGlobMatcher.MatchContext matchContext) { var candidateFullPath = Path.GetFullPath(candidate.GetMetadata("FullPath")); if (contentRoot == null) @@ -397,8 +307,12 @@ private string AppendFingerprintPattern( // publish processes, so we want to allow defining these assets by setting up a different content root path from their // original location in the project. For example the asset can be wwwroot\my-prod-asset.js, the content root can be // obj\transform and the final asset identity can be <>\obj\transform\my-prod-asset.js - - var matchResult = matcher?.Match(StaticWebAssetPathPattern.PathWithoutTokens(candidate.ItemSpec)); + GlobMatch matchResult = default; + if (matcher != null) + { + matchContext.SetPathAndReinitialize(StaticWebAssetPathPattern.PathWithoutTokens(candidate.ItemSpec)); + matchResult = matcher.Match(matchContext); + } if (matcher == null) { // If no relative path pattern was specified, we are going to suggest that the identity is `%(ContentRoot)\RelativePath\OriginalFileName` @@ -410,14 +324,14 @@ private string AppendFingerprintPattern( Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it did not start with the content root '{2}'", candidate.ItemSpec, finalIdentity, normalizedContentRoot); return (finalIdentity, true); } - else if (!matchResult.HasMatches) + else if (!matchResult.IsMatch) { Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it didn't match the relative path pattern", candidate.ItemSpec, candidateFullPath); return (candidateFullPath, false); } else { - var stem = matchResult.Files.Single().Stem; + var stem = matchResult.Stem; var assetIdentity = Path.GetFullPath(Path.Combine(normalizedContentRoot, stem)); Log.LogMessage(MessageImportance.Low, "Computed identity '{0}' for candidate '{1}'", assetIdentity, candidate.ItemSpec); @@ -489,11 +403,7 @@ private string GetCandidateMatchPath(ITaskItem candidate) var normalizedAssetPath = Path.GetFullPath(candidate.GetMetadata("FullPath")); if (normalizedAssetPath.StartsWith(normalizedContentRoot)) { -#if NET9_0_OR_GREATER - var result = normalizedAssetPath[normalizedContentRoot.Length..]; -#else var result = normalizedAssetPath.Substring(normalizedContentRoot.Length); -#endif Log.LogMessage(MessageImportance.Low, "FullPath '{0}' starts with content root '{1}' for candidate '{2}'. Using '{3}' as relative path.", normalizedAssetPath, normalizedContentRoot, candidate.ItemSpec, result); return result; } @@ -618,16 +528,4 @@ private string GetDiscoveryCandidateMatchPath(ITaskItem candidate) return computedPath; } - - private sealed class FingerprintPattern(ITaskItem pattern) - { - Matcher _matcher; - public string Name { get; set; } = pattern.ItemSpec; - - public string Pattern { get; set; } = pattern.GetMetadata(nameof(Pattern)); - - public string Expression { get; set; } = pattern.GetMetadata(nameof(Expression)); - - public Matcher Matcher => _matcher ??= new Matcher().AddInclude(Pattern); - } } diff --git a/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs index 49bb24410e0c..4c3ab6764e5a 100644 --- a/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs +++ b/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs @@ -44,7 +44,7 @@ public override bool Execute() continue; } - if (FilterStaticWebAssetEndpoints.MeetsAllCriteria(endpoint, asset, filterCriteria, out var failingCriteria)) + if (MeetsAllCriteria(endpoint, asset, filterCriteria, out var failingCriteria)) { if (asset != null && !endpointFoundMatchingAsset.ContainsKey(asset.Identity)) { diff --git a/src/StaticWebAssetsSdk/Tasks/FingerprintPatternMatcher.cs b/src/StaticWebAssetsSdk/Tasks/FingerprintPatternMatcher.cs new file mode 100644 index 000000000000..cf96937fecba --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/FingerprintPatternMatcher.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +internal class FingerprintPatternMatcher +{ + private const string DefaultFingerprintExpression = "#[.{fingerprint}]?"; + + private readonly TaskLoggingHelper _log; + private readonly Dictionary _tokensByPattern; + private readonly StaticWebAssetGlobMatcher _matcher; + + public FingerprintPatternMatcher( + TaskLoggingHelper log, + ITaskItem[] fingerprintPatterns) + { + var tokensByPattern = fingerprintPatterns + .ToDictionary( + p => p.GetMetadata("Pattern"), + p => p.GetMetadata("Expression") is string expr and not "" ? expr : DefaultFingerprintExpression); + + _log = log; + _tokensByPattern = tokensByPattern; + + var builder = new StaticWebAssetGlobMatcherBuilder(); + foreach (var pattern in fingerprintPatterns) + { + builder.AddIncludePatterns(pattern.GetMetadata("Pattern")); + } + + _matcher = builder.Build(); + } + + public string AppendFingerprintPattern(StaticWebAssetGlobMatcher.MatchContext context, string identity) + { + var relativePathCandidateMemory = context.PathString.AsMemory(); + if (AlreadyContainsFingerprint(relativePathCandidateMemory, identity)) + { + return relativePathCandidateMemory.ToString(); + } + + var (directoryName, fileName, fileNamePrefix, extension) = +#if NET9_0_OR_GREATER + ComputeFingerprintFragments(relativePathCandidateMemory); +#else + ComputeFingerprintFragments(context.PathString); +#endif + + context.SetPathAndReinitialize(fileName); + var matchResult = _matcher.Match(context); + if (!matchResult.IsMatch) + { +#if NET9_0_OR_GREATER + var result = Path.Combine(directoryName.ToString(), $"{fileNamePrefix}{DefaultFingerprintExpression}{extension}"); +#else + var result = Path.Combine(directoryName, $"{fileNamePrefix}{DefaultFingerprintExpression}{extension}"); +#endif + _log.LogMessage(MessageImportance.Low, "Fingerprinting asset '{0}' as '{1}' because it didn't match any pattern", relativePathCandidateMemory, result); + + return result; + } + else + { + if (!_tokensByPattern.TryGetValue(matchResult.Pattern, out var expression)) + { + throw new InvalidOperationException($"No expression found for pattern '{matchResult.Pattern}'"); + } + else + { + var stem = GetMatchStem(fileName, matchResult.Pattern.AsMemory().Slice(2)); + var matchExtension = GetMatchExtension(fileName, stem); + + var simpleExtensionResult = Path.Combine(directoryName.ToString(), $"{stem}{expression}{matchExtension}"); + _log.LogMessage(MessageImportance.Low, "Fingerprinting asset '{0}' as '{1}'", relativePathCandidateMemory, simpleExtensionResult); + return simpleExtensionResult; + } + } + + static bool AlreadyContainsFingerprint(ReadOnlyMemory relativePathCandidate, string identity) + { + if (MemoryExtensions.Contains(relativePathCandidate.Span, "#[".AsSpan(), StringComparison.Ordinal)) + { + var pattern = StaticWebAssetPathPattern.Parse(relativePathCandidate, identity); + foreach (var segment in pattern.Segments) + { + foreach (var part in segment.Parts) + { + foreach (var name in segment.GetTokenNames()) + { + if (MemoryExtensions.Equals(name.Span, "fingerprint".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + } + return false; + } + +#if NET9_0_OR_GREATER + static ReadOnlySpan GetMatchExtension(ReadOnlySpan relativePathCandidateMemory, ReadOnlySpan stem) => + relativePathCandidateMemory.Slice(stem.Length); + static ReadOnlySpan GetMatchStem(ReadOnlySpan relativePathCandidateMemory, ReadOnlyMemory pattern) => + relativePathCandidateMemory.Slice(0, relativePathCandidateMemory.Length - pattern.Length - 1); +#else + static ReadOnlyMemory GetMatchExtension(ReadOnlyMemory relativePathCandidateMemory, ReadOnlyMemory stem) => + relativePathCandidateMemory.Slice(stem.Length); + static ReadOnlyMemory GetMatchStem(ReadOnlyMemory relativePathCandidateMemory, ReadOnlyMemory pattern) => + relativePathCandidateMemory.Slice(0, relativePathCandidateMemory.Length - pattern.Length - 1); +#endif + } + +#if NET9_0_OR_GREATER + private static FingerprintFragments ComputeFingerprintFragments( + ReadOnlyMemory relativePathCandidate) + { + var fileName = Path.GetFileName(relativePathCandidate.Span); + var directoryName = Path.GetDirectoryName(relativePathCandidate.Span); + var stem = Path.GetFileNameWithoutExtension(relativePathCandidate.Span); + var extension = Path.GetExtension(relativePathCandidate.Span); + + return new(directoryName, fileName, stem, extension); + } +#else + private static (string directoryName, ReadOnlyMemory fileName, ReadOnlyMemory fileNamePrefix, ReadOnlyMemory extension) ComputeFingerprintFragments( + string relativePathCandidate) + { + var fileName = Path.GetFileName(relativePathCandidate).AsMemory(); + var directoryName = Path.GetDirectoryName(relativePathCandidate); + var stem = Path.GetFileNameWithoutExtension(relativePathCandidate).AsMemory(); + var extension = Path.GetExtension(relativePathCandidate).AsMemory(); + + return (directoryName, fileName, stem, extension); + } +#endif + + private ref struct FingerprintFragments + { + public ReadOnlySpan DirectoryName; + public ReadOnlySpan FileName; + public ReadOnlySpan FileNamePrefix; + public ReadOnlySpan Extension; + + public FingerprintFragments(ReadOnlySpan directoryName, ReadOnlySpan fileName, ReadOnlySpan fileNamePrefix, ReadOnlySpan extension) + { + DirectoryName = directoryName; + FileName = fileName; + FileNamePrefix = fileNamePrefix; + Extension = extension; + } + + public void Deconstruct(out ReadOnlySpan directoryName, out ReadOnlySpan fileName, out ReadOnlySpan fileNamePrefix, out ReadOnlySpan extension) + { + directoryName = DirectoryName; + fileName = FileName; + fileNamePrefix = FileNamePrefix; + extension = Extension; + } + } + + private class FingerprintPattern(ITaskItem pattern) + { + StaticWebAssetGlobMatcher _matcher; + public string Name { get; set; } = pattern.ItemSpec; + + public string Pattern { get; set; } = pattern.GetMetadata(nameof(Pattern)); + + public string Expression { get; set; } = pattern.GetMetadata(nameof(Expression)); + + public StaticWebAssetGlobMatcher Matcher => _matcher ??= new StaticWebAssetGlobMatcherBuilder().AddIncludePatterns(Pattern).Build(); + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs index 5b1651ac6c73..98a98c6e999e 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsDevelopmentManifest.cs @@ -91,7 +91,7 @@ public StaticWebAssetsDevelopmentManifest ComputeDevelopmentManifest( return 0; }); - var manifest = GenerateStaticWebAssetsDevelopmentManifest.CreateManifest(assetsWithPathSegments, discoveryPatternsByBasePath); + var manifest = CreateManifest(assetsWithPathSegments, discoveryPatternsByBasePath); return manifest; } diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs index e788b427ccde..8f2d7c7eb786 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs @@ -211,7 +211,7 @@ private bool ValidateMetadataMatches(ITaskItem reference, ITaskItem candidate, s private bool EnsureRequiredMetadata(ITaskItem item, string metadataName, bool allowEmpty = false) { var value = item.GetMetadata(metadataName); - var isInvalidValue = allowEmpty ? !GenerateStaticWebAssetsPropsFile.HasMetadata(item, metadataName) : string.IsNullOrEmpty(value); + var isInvalidValue = allowEmpty ? !HasMetadata(item, metadataName) : string.IsNullOrEmpty(value); if (isInvalidValue) { diff --git a/src/StaticWebAssetsSdk/Tasks/Legacy/GenerateStaticWebAssetsPropsFile50.cs b/src/StaticWebAssetsSdk/Tasks/Legacy/GenerateStaticWebAssetsPropsFile50.cs index 1fae42ffe621..e30ba51878cb 100644 --- a/src/StaticWebAssetsSdk/Tasks/Legacy/GenerateStaticWebAssetsPropsFile50.cs +++ b/src/StaticWebAssetsSdk/Tasks/Legacy/GenerateStaticWebAssetsPropsFile50.cs @@ -202,7 +202,7 @@ private bool ValidateMetadataMatches(ITaskItem reference, ITaskItem candidate, s private bool EnsureRequiredMetadata(ITaskItem item, string metadataName, bool allowEmpty = false) { var value = item.GetMetadata(metadataName); - var isInvalidValue = allowEmpty ? !GenerateStaticWebAssetsPropsFile50.HasMetadata(item, metadataName) : string.IsNullOrEmpty(value); + var isInvalidValue = allowEmpty ? !HasMetadata(item, metadataName) : string.IsNullOrEmpty(value); if (isInvalidValue) { diff --git a/src/StaticWebAssetsSdk/Tasks/Legacy/ValidateStaticWebAssetsUniquePaths.cs b/src/StaticWebAssetsSdk/Tasks/Legacy/ValidateStaticWebAssetsUniquePaths.cs index ab1afaf71cf8..9ce2021882ce 100644 --- a/src/StaticWebAssetsSdk/Tasks/Legacy/ValidateStaticWebAssetsUniquePaths.cs +++ b/src/StaticWebAssetsSdk/Tasks/Legacy/ValidateStaticWebAssetsUniquePaths.cs @@ -30,7 +30,7 @@ public override bool Execute() } else { - var webRootPath = ValidateStaticWebAssetsUniquePaths.GetWebRootPath("/wwwroot", + var webRootPath = GetWebRootPath("/wwwroot", contentRootDefinition.GetMetadata(BasePath), contentRootDefinition.GetMetadata(RelativePath)); @@ -53,7 +53,7 @@ public override bool Execute() { var webRootFile = WebRootFiles[i]; var relativePath = webRootFile.GetMetadata(TargetPath); - var webRootFileWebRootPath = ValidateStaticWebAssetsUniquePaths.GetWebRootPath("", "/", relativePath); + var webRootFileWebRootPath = GetWebRootPath("", "/", relativePath); if (assetsByWebRootPaths.TryGetValue(webRootFileWebRootPath, out var existingAsset)) { Log.LogError($"The static web asset '{existingAsset.ItemSpec}' has a conflicting web root path '{webRootFileWebRootPath}' with the project file '{webRootFile.ItemSpec}'."); diff --git a/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj b/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj index 3289ecdbfdb5..a5ba0b007ad8 100644 --- a/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj +++ b/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj @@ -41,6 +41,10 @@ + + + + @@ -48,12 +52,8 @@ - - + + @@ -63,61 +63,43 @@ - + <_FileSystemGlobbing Include="@(ReferencePath)" Condition="'%(ReferencePath.NuGetPackageId)' == 'Microsoft.Extensions.FileSystemGlobbing'" /> <_FileSystemGlobbingContent Include="@(_FileSystemGlobbing)" TargetPath="tasks\$(TargetFramework)\%(_FileSystemGlobbing.Filename)%(_FileSystemGlobbing.Extension)" /> - + - + - <_CssParser Include="@(ReferencePath->WithMetadataValue('NuGetPackageId', 'Microsoft.Css.Parser'))" /> + <_CssParser Include="@(ReferencePath->WithMetadataValue('NuGetPackageId', 'Microsoft.Css.Parser'))" /> <_CssParserContent Include="@(_CssParser)" TargetPath="tasks\$(TargetFramework)\%(_CssParser.Filename)%(_CssParser.Extension)" /> - + - + - + - + diff --git a/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs b/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs index 1fdf5cf32f12..d87e99381eb5 100644 --- a/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/ResolveFingerprintedStaticWebAssetEndpointsForAssets.cs @@ -65,7 +65,7 @@ public override bool Execute() for (var j = 0; j < endpoints.Length; j++) { var endpoint = endpoints[j]; - if (ResolveFingerprintedStaticWebAssetEndpointsForAssets.HasFingerprint(endpoint)) + if (HasFingerprint(endpoint)) { foundFingerprintedEndpoint = true; var route = asset.ReplaceTokens(endpoint.Route, StaticWebAssetTokenResolver.Instance); diff --git a/src/StaticWebAssetsSdk/Tasks/ScopedCss/ComputeCssScope.cs b/src/StaticWebAssetsSdk/Tasks/ScopedCss/ComputeCssScope.cs index 3e5b9c0480d2..58df586c453c 100644 --- a/src/StaticWebAssetsSdk/Tasks/ScopedCss/ComputeCssScope.cs +++ b/src/StaticWebAssetsSdk/Tasks/ScopedCss/ComputeCssScope.cs @@ -28,7 +28,7 @@ public override bool Execute() var input = ScopedCssInput[i]; var relativePath = input.ItemSpec.ToLowerInvariant().Replace("\\", "//"); var scope = input.GetMetadata("CssScope"); - scope = !string.IsNullOrEmpty(scope) ? scope : ComputeCssScope.GenerateScope(TargetName, relativePath); + scope = !string.IsNullOrEmpty(scope) ? scope : GenerateScope(TargetName, relativePath); var outputItem = new TaskItem(input); outputItem.SetMetadata("CssScope", scope); diff --git a/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles.cs b/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles.cs index 76798c65c2a5..c8022062f4be 100644 --- a/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles.cs +++ b/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles.cs @@ -58,12 +58,12 @@ public override bool Execute() // If we were to produce "/_content/library/bundle.bdl.scp.css" it would fail to accoutn for "subdir" // We could produce shorter paths if we detected common segments between the final bundle base path and the imported bundle // base paths, but its more work and it will not have a significant impact on the bundle size size. - var normalizedBasePath = ConcatenateCssFiles.NormalizePath(ScopedCssBundleBasePath); + var normalizedBasePath = NormalizePath(ScopedCssBundleBasePath); var currentBasePathSegments = normalizedBasePath.Split(_separator, StringSplitOptions.RemoveEmptyEntries); var prefix = string.Join("/", Enumerable.Repeat("..", currentBasePathSegments.Length)); for (var i = 0; i < ProjectBundles.Length; i++) { - var importPath = ConcatenateCssFiles.NormalizePath(Path.Combine(prefix, ProjectBundles[i].ItemSpec)); + var importPath = NormalizePath(Path.Combine(prefix, ProjectBundles[i].ItemSpec)); #if !NET9_0_OR_GREATER builder.AppendLine($"@import '{importPath}';"); @@ -91,7 +91,7 @@ public override bool Execute() var content = builder.ToString(); - if (!File.Exists(OutputFile) || !ConcatenateCssFiles.SameContent(content, OutputFile)) + if (!File.Exists(OutputFile) || !SameContent(content, OutputFile)) { Directory.CreateDirectory(Path.GetDirectoryName(OutputFile)); File.WriteAllText(OutputFile, content); diff --git a/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles50.cs b/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles50.cs index fb198929edf9..26518efedd8b 100644 --- a/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles50.cs +++ b/src/StaticWebAssetsSdk/Tasks/ScopedCss/ConcatenateCssFiles50.cs @@ -58,15 +58,15 @@ public override bool Execute() // If we were to produce "/_content/library/bundle.bdl.scp.css" it would fail to accoutn for "subdir" // We could produce shorter paths if we detected common segments between the final bundle base path and the imported bundle // base paths, but its more work and it will not have a significant impact on the bundle size size. - var normalizedBasePath = ConcatenateCssFiles50.NormalizePath(ScopedCssBundleBasePath); + var normalizedBasePath = NormalizePath(ScopedCssBundleBasePath); var currentBasePathSegments = normalizedBasePath.Split(_separator, StringSplitOptions.RemoveEmptyEntries); var prefix = string.Join("/", Enumerable.Repeat("..", currentBasePathSegments.Length)); for (var i = 0; i < ProjectBundles.Length; i++) { var bundle = ProjectBundles[i]; - var bundleBasePath = ConcatenateCssFiles50.NormalizePath(bundle.GetMetadata("BasePath")); - var relativePath = ConcatenateCssFiles50.NormalizePath(bundle.GetMetadata("RelativePath")); - var importPath = ConcatenateCssFiles50.NormalizePath(Path.Combine(prefix, bundleBasePath, relativePath)); + var bundleBasePath = NormalizePath(bundle.GetMetadata("BasePath")); + var relativePath = NormalizePath(bundle.GetMetadata("RelativePath")); + var importPath = NormalizePath(Path.Combine(prefix, bundleBasePath, relativePath)); #if !NET9_0_OR_GREATER builder.AppendLine($"@import '{importPath}';"); @@ -94,7 +94,7 @@ public override bool Execute() var content = builder.ToString(); - if (!File.Exists(OutputFile) || !ConcatenateCssFiles50.SameContent(content, OutputFile)) + if (!File.Exists(OutputFile) || !SameContent(content, OutputFile)) { Directory.CreateDirectory(Path.GetDirectoryName(OutputFile)); File.WriteAllText(OutputFile, content); diff --git a/src/StaticWebAssetsSdk/Tasks/ScopedCss/RewriteCss.cs b/src/StaticWebAssetsSdk/Tasks/ScopedCss/RewriteCss.cs index 953949728567..01b3c40933b8 100644 --- a/src/StaticWebAssetsSdk/Tasks/ScopedCss/RewriteCss.cs +++ b/src/StaticWebAssetsSdk/Tasks/ScopedCss/RewriteCss.cs @@ -171,7 +171,7 @@ protected override void VisitSelector(Selector selector) var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault(); if (lastSimpleSelector != null) { - Edits.Add(new InsertSelectorScopeEdit { Position = FindScopeInsertionEdits.FindPositionToInsertInSelector(lastSimpleSelector) }); + Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionToInsertInSelector(lastSimpleSelector) }); } else if (firstDeepCombinator != null) { diff --git a/src/StaticWebAssetsSdk/Tasks/ServiceWorker/GenerateServiceWorkerAssetsManifest.cs b/src/StaticWebAssetsSdk/Tasks/ServiceWorker/GenerateServiceWorkerAssetsManifest.cs index 3f28a78d2b92..7f202a6ce536 100644 --- a/src/StaticWebAssetsSdk/Tasks/ServiceWorker/GenerateServiceWorkerAssetsManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/ServiceWorker/GenerateServiceWorkerAssetsManifest.cs @@ -96,9 +96,9 @@ private void PersistManifest(ServiceWorkerManifest manifest) { var data = JsonSerializer.Serialize(manifest, ManifestSerializationOptions); var content = $"self.assetsManifest = {data};{Environment.NewLine}"; - var contentHash = GenerateServiceWorkerAssetsManifest.ComputeFileHash(content); + var contentHash = ComputeFileHash(content); var fileExists = File.Exists(OutputPath); - var existingManifestHash = fileExists ? GenerateServiceWorkerAssetsManifest.ComputeFileHash(File.ReadAllText(OutputPath)) : ""; + var existingManifestHash = fileExists ? ComputeFileHash(File.ReadAllText(OutputPath)) : ""; if (!fileExists) { diff --git a/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs index e88e80c2c1cc..85c9c409eddf 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs @@ -93,13 +93,13 @@ private static bool TryUpdateEndpoint(StaticWebAssetEndpoint endpoint, StaticWeb updated = true; break; case "Remove": - updated |= UpdateStaticWebAssetEndpoints.RemoveFromEndpoint(endpoint, operation); + updated |= RemoveFromEndpoint(endpoint, operation); break; case "Replace": - updated |= UpdateStaticWebAssetEndpoints.ReplaceInEndpoint(endpoint, operation); + updated |= ReplaceInEndpoint(endpoint, operation); break; case "RemoveAll": - updated |= UpdateStaticWebAssetEndpoints.RemoveAllFromEndpoint(endpoint, operation); + updated |= RemoveAllFromEndpoint(endpoint, operation); break; default: throw new InvalidOperationException($"Unknown operation {operation.Type}"); @@ -119,7 +119,7 @@ private static bool RemoveAllFromEndpoint(StaticWebAssetEndpoint endpoint, Stati switch (operation.Target) { case "Selector": - var (selectors, selectorRemoved) = UpdateStaticWebAssetEndpoints.RemoveAllIfFound(endpoint.Selectors, s => s.Name, s => s.Value, operation.Name, operation.Value); + var (selectors, selectorRemoved) = RemoveAllIfFound(endpoint.Selectors, s => s.Name, s => s.Value, operation.Name, operation.Value); if (selectorRemoved) { endpoint.Selectors = selectors; @@ -127,7 +127,7 @@ private static bool RemoveAllFromEndpoint(StaticWebAssetEndpoint endpoint, Stati } break; case "Header": - var (headers, headerRemoved) = UpdateStaticWebAssetEndpoints.RemoveAllIfFound(endpoint.ResponseHeaders, h => h.Name, h => h.Value, operation.Name, operation.Value); + var (headers, headerRemoved) = RemoveAllIfFound(endpoint.ResponseHeaders, h => h.Name, h => h.Value, operation.Name, operation.Value); if (headerRemoved) { endpoint.ResponseHeaders = headers; @@ -135,7 +135,7 @@ private static bool RemoveAllFromEndpoint(StaticWebAssetEndpoint endpoint, Stati } break; case "Property": - var (properties, propertyRemoved) = UpdateStaticWebAssetEndpoints.RemoveAllIfFound(endpoint.EndpointProperties, p => p.Name, p => p.Value, operation.Name, operation.Value); + var (properties, propertyRemoved) = RemoveAllIfFound(endpoint.EndpointProperties, p => p.Name, p => p.Value, operation.Name, operation.Value); if (propertyRemoved) { endpoint.EndpointProperties = properties; @@ -200,7 +200,7 @@ private static bool ReplaceInEndpoint(StaticWebAssetEndpoint endpoint, StaticWeb switch (operation.Target) { case "Selector": - var (selectors, selectorReplaced) = UpdateStaticWebAssetEndpoints.ReplaceFirstIfFound( + var (selectors, selectorReplaced) = ReplaceFirstIfFound( endpoint.Selectors, s => s.Name, s => s.Value, @@ -215,7 +215,7 @@ private static bool ReplaceInEndpoint(StaticWebAssetEndpoint endpoint, StaticWeb } break; case "Header": - var (headers, headerReplaced) = UpdateStaticWebAssetEndpoints.ReplaceFirstIfFound( + var (headers, headerReplaced) = ReplaceFirstIfFound( endpoint.ResponseHeaders, h => h.Name, h => h.Value, @@ -230,7 +230,7 @@ private static bool ReplaceInEndpoint(StaticWebAssetEndpoint endpoint, StaticWeb } break; case "Property": - var (properties, propertyReplaced) = UpdateStaticWebAssetEndpoints.ReplaceFirstIfFound( + var (properties, propertyReplaced) = ReplaceFirstIfFound( endpoint.EndpointProperties, p => p.Name, p => p.Value, @@ -277,7 +277,7 @@ private static bool RemoveFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWe switch (operation.Target) { case "Selector": - var (selectors, selectorRemoved) = UpdateStaticWebAssetEndpoints.RemoveFirstIfFound(endpoint.Selectors, s => s.Name, s => s.Value, operation.Name, operation.Value); + var (selectors, selectorRemoved) = RemoveFirstIfFound(endpoint.Selectors, s => s.Name, s => s.Value, operation.Name, operation.Value); if (selectorRemoved) { endpoint.Selectors = selectors; @@ -285,7 +285,7 @@ private static bool RemoveFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWe } break; case "Header": - var (headers, headerRemoved) = UpdateStaticWebAssetEndpoints.RemoveFirstIfFound(endpoint.ResponseHeaders, h => h.Name, h => h.Value, operation.Name, operation.Value); + var (headers, headerRemoved) = RemoveFirstIfFound(endpoint.ResponseHeaders, h => h.Name, h => h.Value, operation.Name, operation.Value); if (headerRemoved) { endpoint.ResponseHeaders = headers; @@ -293,7 +293,7 @@ private static bool RemoveFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWe } break; case "Property": - var (properties, propertyRemoved) = UpdateStaticWebAssetEndpoints.RemoveFirstIfFound(endpoint.EndpointProperties, p => p.Name, p => p.Value, operation.Name, operation.Value); + var (properties, propertyRemoved) = RemoveFirstIfFound(endpoint.EndpointProperties, p => p.Name, p => p.Value, operation.Name, operation.Value); if (propertyRemoved) { endpoint.EndpointProperties = properties; diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobMatch.cs b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobMatch.cs new file mode 100644 index 000000000000..c35113f518d6 --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobMatch.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +public struct GlobMatch(bool isMatch, string pattern = null, string stem = null) +{ + public bool IsMatch { get; set; } = isMatch; + + public string Pattern { get; set; } = pattern; + + public string Stem { get; set; } = stem; +} diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobNode.cs b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobNode.cs new file mode 100644 index 000000000000..65d7fb27d438 --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/GlobNode.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +public class GlobNode +{ + public string Match { get; set; } + +#if NET9_0_OR_GREATER + public Dictionary LiteralsDictionary { get; set; } + public Dictionary.AlternateLookup> Literals { get; set; } +#else + public Dictionary Literals { get; set; } +#endif + +#if NET9_0_OR_GREATER + public Dictionary ExtensionsDictionary { get; set; } + public Dictionary.AlternateLookup> Extensions { get; set; } +#else + public Dictionary Extensions { get; set; } +#endif + + public List ComplexGlobSegments { get; set; } + + public GlobNode WildCard { get; set; } + + public GlobNode RecursiveWildCard { get; set; } + + internal bool HasChildren() + { +#if NET9_0_OR_GREATER + return LiteralsDictionary?.Count > 0 || ExtensionsDictionary?.Count > 0 || ComplexGlobSegments?.Count > 0 || WildCard != null || RecursiveWildCard != null; +#else + return Literals?.Count > 0 || Extensions?.Count > 0 || ComplexGlobSegments?.Count > 0 || WildCard != null || RecursiveWildCard != null; +#endif + } + + private string GetDebuggerDisplay() + { + return ToString(); + } + + public override string ToString() + { +#if NET9_0_OR_GREATER + var literals = $$"""{{{string.Join(", ", LiteralsDictionary?.Keys ?? Enumerable.Empty())}}}"""; + var extensions = $$"""{{{string.Join(", ", ExtensionsDictionary?.Keys ?? Enumerable.Empty())}}}"""; +#else + var literals = $$"""{{{string.Join(", ", Literals?.Keys ?? Enumerable.Empty())}}}"""; + var extensions = $$"""{{{string.Join(", ", Extensions?.Keys ?? Enumerable.Empty())}}}"""; +#endif + var wildCard = WildCard != null ? "*" : string.Empty; + var recursiveWildCard = RecursiveWildCard != null ? "**" : string.Empty; + return $"{literals}|{extensions}|{wildCard}|{recursiveWildCard}"; + } + + internal bool HasLiterals() + { +#if NET9_0_OR_GREATER + return LiteralsDictionary?.Count > 0; +#else + return Literals?.Count > 0; +#endif + } + + internal bool HasExtensions() + { +#if NET9_0_OR_GREATER + return ExtensionsDictionary?.Count > 0; +#else + return Extensions?.Count > 0; +#endif + } +} + +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +public class ComplexGlobSegment +{ + public GlobNode Node { get; set; } + public List Parts { get; set; } + + private string GetDebuggerDisplay() => ToString(); + + public override string ToString() => string.Join("", Parts.Select(p => p.ToString())); +} + +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +public class GlobSegmentPart +{ + public GlobSegmentPartKind Kind { get; set; } + public ReadOnlyMemory Value { get; set; } + + private string GetDebuggerDisplay() => ToString(); + + public override string ToString() => Kind switch + { + GlobSegmentPartKind.Literal => Value.ToString(), + GlobSegmentPartKind.WildCard => "*", + GlobSegmentPartKind.QuestionMark => "?", + _ => throw new InvalidOperationException(), + }; +} + +public enum GlobSegmentPartKind +{ + Literal, + WildCard, + QuestionMark, +} diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/PathTokenizer.cs b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/PathTokenizer.cs new file mode 100644 index 000000000000..84860aea7ef0 --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/PathTokenizer.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +#if !NET9_0_OR_GREATER +public ref struct PathTokenizer(ReadOnlySpan path) +{ + private readonly ReadOnlySpan _path = path; + int _index = -1; + int _nextSeparatorIndex = -1; + + public readonly Segment Current => + new (_index, (_nextSeparatorIndex == -1 ? _path.Length : _nextSeparatorIndex) - _index); + + public bool MoveNext() + { + if (_index != -1 && _nextSeparatorIndex == -1) + { + return false; + } + + _index = _nextSeparatorIndex + 1; + _nextSeparatorIndex = GetSeparator(); + return true; + } + + internal SegmentCollection Fill(List segments) + { + while (MoveNext()) + { + if (Current.Length > 0 && + !_path.Slice(Current.Start, Current.Length).Equals(".".AsSpan(), StringComparison.Ordinal) && + !_path.Slice(Current.Start, Current.Length).Equals("..".AsSpan(), StringComparison.Ordinal)) + { + segments.Add(Current); + } + } + + return new SegmentCollection(_path, segments); + } + + private readonly int GetSeparator() => _path.Slice(_index).IndexOfAny(OSPath.DirectoryPathSeparators.Span) switch + { + -1 => -1, + var index => index + _index + }; + + public struct Segment(int start, int length) + { + public int Start { get; set; } = start; + public int Length { get; set; } = length; + } + + public readonly ref struct SegmentCollection(ReadOnlySpan path, List segments) + { + private readonly ReadOnlySpan _path = path; + private readonly int _index = 0; + + private SegmentCollection(ReadOnlySpan path, List segments, int index) : this(path, segments) => + _index = index; + + public int Count => segments.Count - _index; + + public ReadOnlySpan this[int index] => _path.Slice(segments[index + _index].Start, segments[index + _index].Length); + + public ReadOnlyMemory this[ReadOnlyMemory path, int index] => path.Slice(segments[index + _index].Start, segments[index + _index].Length); + + internal SegmentCollection Slice(int segmentIndex) => new (_path, segments, segmentIndex); + } +} +#else +public ref struct PathTokenizer(ReadOnlySpan path) +{ + private readonly ReadOnlySpan _path = path; + + public struct Segment(int start, int length) + { + public int Start { get; set; } = start; + public int Length { get; set; } = length; + } + + internal SegmentCollection Fill(List segments) + { + foreach (var range in MemoryExtensions.SplitAny(_path, OSPath.DirectoryPathSeparators.Span)) + { + var length = range.End.Value - range.Start.Value; + if (length > 0 && + !_path.Slice(range.Start.Value, length).Equals(".".AsSpan(), StringComparison.Ordinal) && + !_path.Slice(range.Start.Value, length).Equals("..".AsSpan(), StringComparison.Ordinal)) + { + segments.Add(new(range.Start.Value, length)); + } + } + + return new SegmentCollection(_path, segments); + } + + public readonly ref struct SegmentCollection(ReadOnlySpan path, List segments) + { + private readonly ReadOnlySpan _path = path; + private readonly int _index = 0; + + private SegmentCollection(ReadOnlySpan path, List segments, int index) : this(path, segments) => + _index = index; + + public int Count => segments.Count - _index; + + public ReadOnlySpan this[int index] => _path.Slice(segments[index + _index].Start, segments[index + _index].Length); + + public ReadOnlyMemory this[ReadOnlyMemory path, int index] => path.Slice(segments[index + _index].Start, segments[index + _index].Length); + + internal SegmentCollection Slice(int segmentIndex) => new(_path, segments, segmentIndex); + } +} +#endif diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcher.cs b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcher.cs new file mode 100644 index 000000000000..18ea7fad699f --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcher.cs @@ -0,0 +1,551 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +public class StaticWebAssetGlobMatcher(GlobNode includes, GlobNode excludes) +{ + // For testing only + internal GlobMatch Match(string path) + { + var context = CreateMatchContext(); + context.SetPathAndReinitialize(path); + return Match(context); + } + + public GlobMatch Match(MatchContext context) + { + var stateStack = context.MatchStates; + var tokenizer = new PathTokenizer(context.Path); + var segments = tokenizer.Fill(context.Segments); + if (segments.Count == 0) + { + return new(false, string.Empty); + } + + if (excludes != null) + { + var excluded = MatchCore(excludes, segments, stateStack); + if (excluded.IsMatch) + { + return new(false, null); + } + } + + return MatchCore(includes, segments, stateStack); + } + + private static GlobMatch MatchCore(GlobNode includes, PathTokenizer.SegmentCollection segments, Stack stateStack) + { + stateStack.Push(new(includes)); + while (stateStack.Count > 0) + { + var state = stateStack.Pop(); + var stage = state.Stage; + var currentIndex = state.SegmentIndex; + var node = state.Node; + + switch (stage) + { + case MatchStage.Done: + if (currentIndex == segments.Count) + { + if (node.Match != null) + { + var stem = ComputeStem(segments, state.StemStartIndex); + return new(true, node.Match, stem); + } + + // We got to the end with no matches, pop the next element on the stack. + continue; + } + break; + case MatchStage.Literal: + if (currentIndex == segments.Count) + { + // We ran out of segments to match + continue; + } + PushNextStageIfAvailable(stateStack, state); + MatchLiteral(segments, stateStack, state); + break; + case MatchStage.Extension: + if (currentIndex == segments.Count) + { + // We ran out of segments to match + continue; + } + PushNextStageIfAvailable(stateStack, state); + MatchExtension(segments, stateStack, state); + break; + case MatchStage.Complex: + if (currentIndex == segments.Count) + { + // We ran out of segments to match + continue; + } + PushNextStageIfAvailable(stateStack, state); + MatchComplex(segments, stateStack, state); + break; + case MatchStage.WildCard: + if (currentIndex == segments.Count) + { + // We ran out of segments to match + continue; + } + PushNextStageIfAvailable(stateStack, state); + MatchWildCard(stateStack, state); + break; + case MatchStage.RecursiveWildCard: + MatchRecursiveWildCard(segments, stateStack, state); + break; + } + } + + return new(false, null); + } + + private static string ComputeStem(PathTokenizer.SegmentCollection segments, int stemStartIndex) + { + if (stemStartIndex == -1) + { + return segments[segments.Count - 1].ToString(); + } +#if NET9_0_OR_GREATER + var stemLength = 0; + for (var i = stemStartIndex; i < segments.Count; i++) + { + stemLength += segments[i].Length; + } + // Separators + stemLength += segments.Count - stemStartIndex - 1; + + return string.Create(stemLength, segments.Slice(stemStartIndex), (span, segments) => + { + var index = 0; + for (var i = 0; i < segments.Count; i++) + { + var segment = segments[i]; + segment.CopyTo(span.Slice(index)); + index += segment.Length; + if (i < segments.Count - 1) + { + span[index++] = '/'; + } + } + }); +#else + var stem = new StringBuilder(); + for (var i = stemStartIndex; i < segments.Count; i++) + { + stem.Append(segments[i].ToString()); + if (i < segments.Count - 1) + { + stem.Append('/'); + } + } + return stem.ToString(); +#endif + } + + private static void MatchComplex(PathTokenizer.SegmentCollection segments, Stack stateStack, MatchState state) + { + // We need to try all the complex segments until we find one that matches or we run out of segments to try. + // If we find a match for the current segment, we need to make sure that the rest of the segments match the remainder of the pattern. + // For that reason, if we find a match, we need to push a state that will try the next complex segment in the list (if any) and one + // state that will try the next segment in the current match, so that if for some reason the rest of the pattern doesn't match, we can + // continue trying the rest of the complex segments. + var complexSegmentIndex = state.ComplexSegmentIndex; + var currentIndex = state.SegmentIndex; + var node = state.Node; + var segment = segments[currentIndex]; + var complexSegment = node.ComplexGlobSegments[complexSegmentIndex]; + var parts = complexSegment.Parts; + + if (TryMatchParts(segment, parts)) + { + // We have a match for the current segment + if (complexSegmentIndex + 1 < node.ComplexGlobSegments.Count) + { + // Push a state that will try the next complex segment + stateStack.Push(state.NextComplex()); + } + + // Push a state to try the remainder of the segments + stateStack.Push(state.NextSegment(complexSegment.Node)); + } + } + + private static bool TryMatchParts(ReadOnlySpan span, List parts, int index = 0, int partIndex = 0) + { + for (var i = partIndex; i < parts.Count; i++) + { + if (index > span.Length) + { + // No more characters to consume but we still have parts to process + return false; + } + + var part = parts[i]; + switch (part.Kind) + { + case GlobSegmentPartKind.Literal: + if (!span.Slice(index).StartsWith(part.Value.Span, StringComparison.OrdinalIgnoreCase)) + { + // Literal didn't match + return false; + } + index += part.Value.Length; + break; + case GlobSegmentPartKind.QuestionMark: + index++; + break; + case GlobSegmentPartKind.WildCard: + // Wildcards require trying to match 0 or more characters, so we need to try matching the rest of the parts after + // having consumed 0, 1, 2, ... characters and so on. + // Instead of jumping 0, 1, 2, etc, we are going to calculate the next step by finding the next literal on the list. + // If we find another * we can discard the current one. + // If we find one or moe '?' we can require that at least as many characters as '?' are consumed. + // When we find a literal, we can try to find the index of the literal in the remaining string, and if we find it, we can + // try to match the rest of the parts, jumping ahead after the literal. + // If we happen to not find a literal, we have a match (trailing *) or at most we can require that there are N characters + // left in the string, where N is the number of '?' in the remaining parts. + var minimumCharactersToConsume = 0; + for (var j = i + 1; j < parts.Count; j++) + { + var nextPart = parts[j]; + switch (nextPart.Kind) + { + case GlobSegmentPartKind.Literal: + // Start searching after the current index + the minimum characters to consume + var remainingSpan = span.Slice(index + minimumCharactersToConsume); + var nextLiteralIndex = remainingSpan.IndexOf(nextPart.Value.Span, StringComparison.OrdinalIgnoreCase); + while (nextLiteralIndex != -1) + { + // Consume the characters before the literal and the literal itself before we try + // to match the rest of the parts. + remainingSpan = remainingSpan.Slice(nextLiteralIndex + nextPart.Value.Length); + + if (remainingSpan.Length == 0 && j == parts.Count - 1) + { + // We were looking at the last literal, so we have a match + return true; + } + + if (!TryMatchParts(remainingSpan, parts, 0, j + 1)) + { + // If we couldn't match the rest of the parts, try the next literal + nextLiteralIndex = remainingSpan.IndexOf(nextPart.Value.Span, StringComparison.OrdinalIgnoreCase); + } + else + { + return true; + } + } + // At this point we couldn't match the next literal, in the list, so this pattern is not a match + return false; + case GlobSegmentPartKind.QuestionMark: + minimumCharactersToConsume++; + break; + case GlobSegmentPartKind.WildCard: + // Ignore any wildcard that comes right after the original one + break; + } + } + + // There were no trailing literals, so we have a match if there are at least as many characters as '?' in the remaining parts + return index + minimumCharactersToConsume <= span.Length; + } + } + + return index == span.Length; + } + + private static void MatchRecursiveWildCard(PathTokenizer.SegmentCollection segments, Stack stateStack, MatchState state) + { + var node = state.Node; + for (var i = segments.Count - state.SegmentIndex; i >= 0; i--) + { + var nextSegment = state.NextSegment(node.RecursiveWildCard, i); + // The stem is calculated as the first time the /**/ pattern is matched til the remainder of the path, otherwise, the stem is + // the file name. + if (nextSegment.StemStartIndex == -1) + { + nextSegment.StemStartIndex = state.SegmentIndex; + } + + stateStack.Push(nextSegment); + } + } + + private static void MatchWildCard(Stack stateStack, MatchState state) + { + // A wildcard matches any segment, so we can continue with the next + stateStack.Push(state.NextSegment(state.Node.WildCard)); + } + + private static void MatchExtension(PathTokenizer.SegmentCollection segments, Stack stateStack, MatchState state) + { + var node = state.Node; + var currentIndex = state.SegmentIndex; + var extensionIndex = state.ExtensionSegmentIndex; + var segment = segments[currentIndex]; + if (extensionIndex >= segment.Length) + { + // We couldn't find any path that matched the extensions we have + return; + } + + // We start from something.else.txt matching.else.txt and then .txt + var remaining = segment.Slice(extensionIndex); + var indexOfDot = remaining.IndexOf('.'); + if (indexOfDot != -1) + { + if (TryMatchExtension(node, remaining.Slice(indexOfDot), out var extensionCandidate)) + { + stateStack.Push(state.NextSegment(extensionCandidate)); + } + else + { + // If we fail to match, try and match the next extension. + stateStack.Push(state.NextExtension(extensionIndex + indexOfDot + 1)); + } + } + } + + private static void MatchLiteral(PathTokenizer.SegmentCollection segments, Stack stateStack, MatchState state) + { + var currentIndex = state.SegmentIndex; + var node = state.Node; + // Push the next stage to the stack so we can continue searching in case we don't match the entire path + PushNextStageIfAvailable(stateStack, state); + if (TryMatchLiteral(node, segments[currentIndex], out var literalCandidate)) + { + // Push the found node to the stack to match the remaining path segments + stateStack.Push(state.NextSegment(literalCandidate)); + } + } + + private static void PushNextStageIfAvailable(Stack stateStack, MatchState state) + { + if (state.ExtensionSegmentIndex == 0 && state.ComplexSegmentIndex == 0) + { + var nextStage = state.NextStage(); + if (nextStage.HasValue) + { + stateStack.Push(nextStage); + } + } + } + + [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] + internal struct MatchState(GlobNode node, MatchStage stage, int segmentIndex, int extensionSegmentIndex, int complexSegmentIndex) + { + public MatchState(GlobNode node) : this(node, GetInitialStage(node), 0, 0, 0) { } + + public GlobNode Node { get; set; } = node; + + public MatchStage Stage { get; set; } = stage; + + // Index on the list of segments for the path + public int SegmentIndex { get; set; } = segmentIndex; + + public int ExtensionSegmentIndex { get; set; } = extensionSegmentIndex; + + public int ComplexSegmentIndex { get; set; } = complexSegmentIndex; + + public int StemStartIndex { get; set; } = -1; + + internal readonly bool HasValue => Node != null; + + public readonly void Deconstruct(out GlobNode node, out MatchStage stage, out int segmentIndex, out int extensionIndex, out int complexIndex) + { + node = Node; + stage = Stage; + segmentIndex = SegmentIndex; + extensionIndex = ExtensionSegmentIndex; + complexIndex = ComplexSegmentIndex; + } + + internal MatchState NextSegment(GlobNode candidate, int elements = 1, int complexIndex = 0) => + new(candidate, GetInitialStage(candidate), SegmentIndex + elements, 0, complexIndex) { StemStartIndex = StemStartIndex }; + + internal MatchState NextStage() + { + switch (Stage) + { + case MatchStage.Literal: + if (Node.HasExtensions()) + { + return new(Node, MatchStage.Extension, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + + if (Node.ComplexGlobSegments != null && Node.ComplexGlobSegments.Count > 0) + { + return new(Node, MatchStage.Complex, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + + if (Node.WildCard != null) + { + return new(Node, MatchStage.WildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + + if (Node.RecursiveWildCard != null) + { + return new(Node, MatchStage.RecursiveWildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + break; + case MatchStage.Extension: + if (Node.ComplexGlobSegments != null && Node.ComplexGlobSegments.Count > 0) + { + return new(Node, MatchStage.Complex, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + + if (Node.WildCard != null) + { + return new(Node, MatchStage.WildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + + if (Node.RecursiveWildCard != null) + { + return new(Node, MatchStage.RecursiveWildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + break; + case MatchStage.Complex: + if (Node.WildCard != null) + { + return new(Node, MatchStage.WildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + if (Node.RecursiveWildCard != null) + { + return new(Node, MatchStage.RecursiveWildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + break; + case MatchStage.WildCard: + if (Node.RecursiveWildCard != null) + { + return new(Node, MatchStage.RecursiveWildCard, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + break; + case MatchStage.RecursiveWildCard: + return new(Node, MatchStage.Done, SegmentIndex, 0, 0) + { StemStartIndex = StemStartIndex }; + } + + return default; + } + + private static MatchStage GetInitialStage(GlobNode node) + { + if (node.HasLiterals()) + { + return MatchStage.Literal; + } + + if (node.HasExtensions()) + { + return MatchStage.Extension; + } + + if (node.ComplexGlobSegments != null && node.ComplexGlobSegments.Count > 0) + { + return MatchStage.Complex; + } + + if (node.WildCard != null) + { + return MatchStage.WildCard; + } + + if (node.RecursiveWildCard != null) + { + return MatchStage.RecursiveWildCard; + } + + return MatchStage.Done; + } + + internal readonly MatchState NextExtension(int extensionIndex) => new(Node, MatchStage.Extension, SegmentIndex, extensionIndex, ComplexSegmentIndex); + + internal readonly MatchState NextComplex() => new(Node, MatchStage.Complex, SegmentIndex, ExtensionSegmentIndex, ComplexSegmentIndex + 1); + + private readonly string GetDebuggerDisplay() + { + return $"Node: {Node}, Stage: {Stage}, SegmentIndex: {SegmentIndex}, ExtensionIndex: {ExtensionSegmentIndex}, ComplexSegmentIndex: {ComplexSegmentIndex}"; + } + + } + + internal enum MatchStage + { + Done, + Literal, + Extension, + Complex, + WildCard, + RecursiveWildCard + } + + private static bool TryMatchExtension(GlobNode node, ReadOnlySpan extension, out GlobNode extensionCandidate) => +#if NET9_0_OR_GREATER + node.Extensions.TryGetValue(extension, out extensionCandidate); +#else + node.Extensions.TryGetValue(extension.ToString(), out extensionCandidate); +#endif + + private static bool TryMatchLiteral(GlobNode node, ReadOnlySpan current, out GlobNode nextNode) => +#if NET9_0_OR_GREATER + node.Literals.TryGetValue(current, out nextNode); +#else + node.Literals.TryGetValue(current.ToString(), out nextNode); +#endif + + // The matchContext holds all the state for the underlying matching algorithm. + // It is reused so that we avoid allocating memory for each match. + // It is not thread-safe and should not be shared across threads. + public static MatchContext CreateMatchContext() => new(); + + public ref struct MatchContext() + { + public ReadOnlySpan Path; + public string PathString; + + internal List Segments { get; set; } = []; + internal Stack MatchStates { get; set; } = []; + + public void SetPathAndReinitialize(string path) + { + PathString = path; + Path = path.AsSpan(); + Segments.Clear(); + MatchStates.Clear(); + } + + public void SetPathAndReinitialize(ReadOnlySpan path) + { + Path = path; + Segments.Clear(); + MatchStates.Clear(); + } + + public void SetPathAndReinitialize(ReadOnlyMemory path) + { + Path = path.Span; + Segments.Clear(); + MatchStates.Clear(); + } + + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcherBuilder.cs b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcherBuilder.cs new file mode 100644 index 000000000000..4a9a23725d75 --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/Globbing/StaticWebAssetGlobMatcherBuilder.cs @@ -0,0 +1,227 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +public class StaticWebAssetGlobMatcherBuilder +{ + private readonly List _includePatterns = []; + private readonly List _excludePatterns = []; + +#if NET9_0_OR_GREATER + public StaticWebAssetGlobMatcherBuilder AddIncludePatterns(params Span patterns) +#else + public StaticWebAssetGlobMatcherBuilder AddIncludePatterns(params string[] patterns) +#endif + { + _includePatterns.AddRange(patterns); + return this; + } + + public StaticWebAssetGlobMatcherBuilder AddIncludePatternsList(ICollection patterns) + { + _includePatterns.AddRange(patterns); + return this; + } + +#if NET9_0_OR_GREATER + public StaticWebAssetGlobMatcherBuilder AddExcludePatterns(params Span patterns) +#else + public StaticWebAssetGlobMatcherBuilder AddExcludePatterns(params string[] patterns) +#endif + { + _excludePatterns.AddRange(patterns); + return this; + } + + public StaticWebAssetGlobMatcherBuilder AddExcludePatternsList(ICollection patterns) + { + _excludePatterns.AddRange(patterns); + return this; + } + + public StaticWebAssetGlobMatcher Build() + { + var includeRoot = new GlobNode(); + GlobNode excludeRoot = null; + var segments = new List>(); + BuildTree(includeRoot, _includePatterns, segments); + if (_excludePatterns.Count > 0) + { + excludeRoot = new GlobNode(); + BuildTree(excludeRoot, _excludePatterns, segments); + } + + return new StaticWebAssetGlobMatcher(includeRoot, excludeRoot); + } + + private static void BuildTree(GlobNode root, List patterns, List> segments) + { + for (var i = 0; i < patterns.Count; i++) + { + var pattern = patterns[i]; + var patternMemory = pattern.AsMemory(); + var tokenizer = new PathTokenizer(patternMemory.Span); + segments.Clear(); + var tokenRanges = new List(); + var collection = tokenizer.Fill(tokenRanges); + for (var j = 0; j < collection.Count; j++) + { + var segment = collection[patternMemory, j]; + segments.Add(segment); + } + if (patternMemory.Span.EndsWith("/".AsSpan()) || patternMemory.Span.EndsWith("\\".AsSpan())) + { + segments.Add("**".AsMemory()); + } + var current = root; + for (var j = 0; j < segments.Count; j++) + { + var segment = segments[j]; + if (segment.Length == 0) + { + continue; + } + + var segmentSpan = segment.Span; + if (TryAddRecursiveWildCard(segmentSpan, ref current) || + TryAddWildcard(segmentSpan, ref current) || + TryAddExtension(segment, segmentSpan, ref current) || + TryAddComplexSegment(segment, segmentSpan, ref current) || + TryAddLiteral(segment, ref current)) + { + continue; + } + } + + current.Match = pattern; + } + } + private static bool TryAddLiteral(ReadOnlyMemory segment, ref GlobNode current) + { +#if NET9_0_OR_GREATER + current.LiteralsDictionary ??= new(StringComparer.OrdinalIgnoreCase); + current.Literals = current.Literals.Dictionary != null ? current.Literals : current.LiteralsDictionary.GetAlternateLookup>(); +#else + current.Literals ??= new Dictionary(StringComparer.OrdinalIgnoreCase); +#endif + var literal = segment.ToString(); + if (!current.Literals.TryGetValue(literal, out var literalNode)) + { + literalNode = new GlobNode(); +#if NET9_0_OR_GREATER + current.LiteralsDictionary[literal] = literalNode; +#else + current.Literals[literal] = literalNode; +#endif + } + + current = literalNode; + return true; + } + + private static bool TryAddComplexSegment(ReadOnlyMemory segment, ReadOnlySpan segmentSpan, ref GlobNode current) + { + var searchValues = "*?".AsSpan(); + var variableIndex = segmentSpan.IndexOfAny(searchValues); + if (variableIndex != -1) + { + var lastSegmentIndex = -1; + var complexSegment = new ComplexGlobSegment() + { + Node = new GlobNode(), + Parts = [] + }; + current.ComplexGlobSegments ??= [complexSegment]; + var parts = complexSegment.Parts; + while (variableIndex != -1) + { + if (variableIndex > lastSegmentIndex + 1) + { + parts.Add(new GlobSegmentPart + { + Kind = GlobSegmentPartKind.Literal, + Value = segment.Slice(lastSegmentIndex + 1, variableIndex - lastSegmentIndex - 1) + }); + } + + parts.Add(new GlobSegmentPart + { + Kind = segmentSpan[variableIndex] == '*' ? GlobSegmentPartKind.WildCard : GlobSegmentPartKind.QuestionMark, + Value = "*".AsMemory() + }); + + lastSegmentIndex = variableIndex; + var nextVariableIndex = segmentSpan.Slice(variableIndex + 1).IndexOfAny(searchValues); + variableIndex = nextVariableIndex == -1 ? -1 : variableIndex + 1 + nextVariableIndex; + } + + if (lastSegmentIndex + 1 < segmentSpan.Length) + { + parts.Add(new GlobSegmentPart + { + Kind = GlobSegmentPartKind.Literal, + Value = segment.Slice(lastSegmentIndex + 1) + }); + } + + current = complexSegment.Node; + return true; + } + + return false; + } + + private static bool TryAddExtension(ReadOnlyMemory segment, ReadOnlySpan segmentSpan, ref GlobNode current) + { + if (segmentSpan.StartsWith("*.".AsSpan(), StringComparison.Ordinal) && segmentSpan.LastIndexOf('*') == 0) + { +#if NET9_0_OR_GREATER + current.ExtensionsDictionary ??= new(StringComparer.OrdinalIgnoreCase); + current.Extensions = current.Extensions.Dictionary != null ? current.Extensions : current.ExtensionsDictionary.GetAlternateLookup>(); +#else + current.Extensions ??= new Dictionary(StringComparer.OrdinalIgnoreCase); +#endif + + var extension = segment.Slice(1).ToString(); + if (!current.Extensions.TryGetValue(extension, out var extensionNode)) + { + extensionNode = new GlobNode(); +#if NET9_0_OR_GREATER + current.ExtensionsDictionary[extension] = extensionNode; +#else + current.Extensions[extension] = extensionNode; +#endif + } + current = extensionNode; + return true; + } + + return false; + } + + private static bool TryAddRecursiveWildCard(ReadOnlySpan segmentSpan, ref GlobNode current) + { + if (segmentSpan.Equals("**".AsMemory().Span, StringComparison.Ordinal)) + { + current.RecursiveWildCard ??= new GlobNode(); + current = current.RecursiveWildCard; + return true; + } + + return false; + } + + private static bool TryAddWildcard(ReadOnlySpan segmentSpan, ref GlobNode current) + { + if (segmentSpan.Equals("*".AsMemory().Span, StringComparison.Ordinal)) + { + current.WildCard ??= new GlobNode(); + + current = current.WildCard; + return true; + } + + return false; + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/OSPath.cs b/src/StaticWebAssetsSdk/Tasks/Utils/OSPath.cs index 8b73b76d899d..01480261b441 100644 --- a/src/StaticWebAssetsSdk/Tasks/Utils/OSPath.cs +++ b/src/StaticWebAssetsSdk/Tasks/Utils/OSPath.cs @@ -12,4 +12,6 @@ internal static class OSPath public static StringComparison PathComparison { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + public static ReadOnlyMemory DirectoryPathSeparators { get; } = "/\\".AsMemory(); } diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ContentTypeProviderTests.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ContentTypeProviderTests.cs new file mode 100644 index 000000000000..c2ab1a1fb7b2 --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ContentTypeProviderTests.cs @@ -0,0 +1,155 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.NET.Sdk.Razor.Tests.StaticWebAssets; + +public class ContentTypeProviderTests +{ + private readonly TaskLoggingHelper _log = new TestTaskLoggingHelper(); + + [Fact] + public void GetContentType_ReturnsTextPlainForTextFiles() + { + // Arrange + var provider = new ContentTypeProvider([]); + + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext("Fake-License.txt"), _log); + + // Assert + Assert.Equal("text/plain", contentType.MimeType); + } + + [Fact] + public void GetContentType_ReturnsMappingForRelativePath() + { + // Arrange + var provider = new ContentTypeProvider([]); + + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext("Components/Pages/Counter.razor.js"), _log); + + // Assert + Assert.Equal("text/javascript", contentType.MimeType); + } + + private StaticWebAssetGlobMatcher.MatchContext CreateContext(string v) + { + var ctx = StaticWebAssetGlobMatcher.CreateMatchContext(); + ctx.SetPathAndReinitialize(v); + return ctx; + } + + // wwwroot\exampleJsInterop.js.gz + + [Fact] + public void GetContentType_ReturnsMappingForCompressedRelativePath() + { + // Arrange + var provider = new ContentTypeProvider([]); + + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext("wwwroot/exampleJsInterop.js.gz"), _log); + + // Assert + Assert.Equal("text/javascript", contentType.MimeType); + } + + [Fact] + public void GetContentType_HandlesFingerprintedPaths() + { + // Arrange + var provider = new ContentTypeProvider([]); + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext("_content/RazorPackageLibraryDirectDependency/RazorPackageLibraryDirectDependency#[.{fingerprint}].bundle.scp.css.gz"), _log); + // Assert + Assert.Equal("text/css", contentType.MimeType); + } + + [Fact] + public void GetContentType_ReturnsDefaultForUnknownMappings() + { + // Arrange + var provider = new ContentTypeProvider([]); + + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext("something.unknown"), _log); + + // Assert + Assert.Null(contentType.MimeType); + } + + [Theory] + [InlineData("something.unknown.gz", "application/x-gzip")] + [InlineData("something.unknown.br", "application/octet-stream")] + public void GetContentType_ReturnsGzipOrBrotliForUnknownCompressedMappings(string path, string expectedMapping) + { + // Arrange + var provider = new ContentTypeProvider([]); + + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext(path), _log); + + // Assert + Assert.Equal(expectedMapping, contentType.MimeType); + } + + [Theory] + [InlineData("Fake-License.txt.gz")] + [InlineData("Fake-License.txt.br")] + public void GetContentType_ReturnsTextPlainForCompressedTextFiles(string path) + { + // Arrange + var provider = new ContentTypeProvider([]); + + // Act + var contentType = provider.ResolveContentTypeMapping(CreateContext(path), _log); + + // Assert + Assert.Equal("text/plain", contentType.MimeType); + } + + private class TestTaskLoggingHelper : TaskLoggingHelper + { + public TestTaskLoggingHelper() : base(new TestTask()) + { + } + + private class TestTask : ITask + { + public IBuildEngine BuildEngine { get; set; } = new TestBuildEngine(); + public ITaskHost HostObject { get; set; } = new TestTaskHost(); + + public bool Execute() => true; + } + + private class TestBuildEngine : IBuildEngine + { + public bool ContinueOnError => true; + + public int LineNumberOfTaskNode => 0; + + public int ColumnNumberOfTaskNode => 0; + + public string ProjectFileOfTaskNode => "test.csproj"; + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => true; + + public void LogCustomEvent(CustomBuildEventArgs e) { } + public void LogErrorEvent(BuildErrorEventArgs e) { } + public void LogMessageEvent(BuildMessageEventArgs e) { } + public void LogWarningEvent(BuildWarningEventArgs e) { } + } + + private class TestTaskHost : ITaskHost + { + public object HostObject { get; set; } = new object(); + } + } + +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/DefineStaticWebAssetEndpointsTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/DefineStaticWebAssetEndpointsTest.cs index 3be1bbb905fe..7785aef84c40 100644 --- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/DefineStaticWebAssetEndpointsTest.cs +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/DefineStaticWebAssetEndpointsTest.cs @@ -1,10 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.Metrics; +using System.Diagnostics; using Microsoft.AspNetCore.StaticWebAssets.Tasks; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Moq; +using NuGet.Packaging.Core; +using System.Net; namespace Microsoft.NET.Sdk.Razor.Tests; @@ -374,6 +378,110 @@ public void DoesNotDefineNewEndpointsWhenAnExistingEndpointAlreadyExists() endpoints.Should().BeEmpty(); } + [Fact] + public void ResolvesContentType_ForCompressedAssets() + { + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var lastWrite = new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc); + + var task = new DefineStaticWebAssetEndpoints + { + BuildEngine = buildEngine.Object, + CandidateAssets = [ + new TaskItem( + Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "rdfmaxp4ta-43emfwee4b.gz"), + new Dictionary + { + ["RelativePath"] = "_framework/dotnet.timezones.blat.gz", + ["BasePath"] = "/", + ["AssetMode"] = "All", + ["AssetKind"] = "Build", + ["SourceId"] = "BlazorWasmHosted60.Client", + ["CopyToOutputDirectory"] = "PreserveNewest", + ["Fingerprint"] = "3ji2l2o1xa", + ["RelatedAsset"] = Path.Combine(AppContext.BaseDirectory, "Client", "bin", "Debug", "net6.0", "wwwroot", "_framework", "dotnet.timezones.blat"), + ["ContentRoot"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed"), + ["SourceType"] = "Computed", + ["Integrity"] = "TwfyUDDMyF5dWUB2oRhrZaTk8sEa9o8ezAlKdxypsX4=", + ["AssetRole"] = "Alternative", + ["AssetTraitValue"] = "gzip", + ["AssetTraitName"] = "Content-Encoding", + ["OriginalItemSpec"] = Path.Combine("D:", "work", "dotnet-sdk", "artifacts", "tmp", "Release", "Publish60Host---0200F604", "Client", "bin", "Debug", "net6.0", "wwwroot", "_framework", "dotnet.timezones.blat"), + ["CopyToPublishDirectory"] = "Never" + }) + ], + ExistingEndpoints = [], + ContentTypeMappings = [], + TestLengthResolver = asset => asset.EndsWith(".gz") ? 10 : throw new InvalidOperationException(), + TestLastWriteResolver = asset => asset.EndsWith(".gz") ? lastWrite : throw new InvalidOperationException(), + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().Be(true); + var endpoints = StaticWebAssetEndpoint.FromItemGroup(task.Endpoints); + endpoints.Length.Should().Be(1); + var endpoint = endpoints[0]; + endpoint.ResponseHeaders.Should().ContainSingle(h => h.Name == "Content-Type" && h.Value == "application/x-gzip"); + } + + [Fact] + public void ResolvesContentType_ForFingerprintedAssets() + { + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var lastWrite = new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc); + + var task = new DefineStaticWebAssetEndpoints + { + BuildEngine = buildEngine.Object, + CandidateAssets = [ + new TaskItem( + Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "rdfmaxp4ta-43emfwee4b.gz"), + new Dictionary + { + ["RelativePath"] = "RazorPackageLibraryDirectDependency.iiugt355ct.bundle.scp.css.gz", + ["BasePath"] = "_content/RazorPackageLibraryDirectDependency", + ["AssetMode"] = "Reference", + ["AssetKind"] = "All", + ["SourceId"] = "RazorPackageLibraryDirectDependency", + ["CopyToOutputDirectory"] = "Never", + ["Fingerprint"] = "olx7vzw7zz", + ["RelatedAsset"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "RazorPackageLibraryDirectDependency.iiugt355ct.bundle.scp.css"), + ["ContentRoot"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed"), + ["SourceType"] = "Package", + ["Integrity"] = "JK/W3g5zqZGxAM7zbv/pJ3ngpJheT01SXQ+NofKgQcc=", + ["AssetRole"] = "Alternative", + ["AssetTraitValue"] = "gzip", + ["AssetTraitName"] = "Content-Encoding", + ["OriginalItemSpec"] = Path.Combine(AppContext.BaseDirectory, "Client", "obj", "Debug", "net6.0", "compressed", "RazorPackageLibraryDirectDependency.iiugt355ct.bundle.scp.css"), + ["CopyToPublishDirectory"] = "PreserveNewest" + }) + ], + ExistingEndpoints = [], + ContentTypeMappings = [], + TestLengthResolver = asset => asset.EndsWith(".gz") ? 10 : throw new InvalidOperationException(), + TestLastWriteResolver = asset => asset.EndsWith(".gz") ? lastWrite : throw new InvalidOperationException(), + }; + + // Act + var result = task.Execute(); + result.Should().Be(true); + var endpoints = StaticWebAssetEndpoint.FromItemGroup(task.Endpoints); + endpoints.Length.Should().Be(1); + var endpoint = endpoints[0]; + endpoint.ResponseHeaders.Should().ContainSingle(h => h.Name == "Content-Type" && h.Value == "text/css"); + } + [Fact] public void Produces_TheExpectedEndpoint_ForExternalAssets() { diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/FingerprintPatternMatcherTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/FingerprintPatternMatcherTest.cs new file mode 100644 index 000000000000..9ada363aeb5f --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/FingerprintPatternMatcherTest.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.NET.Sdk.Razor.Tests.StaticWebAssets; + +public class FingerprintPatternMatcherTest +{ + private readonly TaskLoggingHelper _log = new TestTaskLoggingHelper(); + + [Fact] + public void AppendFingerprintPattern_AlreadyContainsFingerprint_ReturnsIdentity() + { + // Arrange + var relativePath = "test#[.{fingerprint}].txt"; + + // Act + var result = new FingerprintPatternMatcher(_log, []).AppendFingerprintPattern(CreateMatchContext(relativePath), "Identity"); + + // Assert + Assert.Equal(relativePath, result); + } + + [Fact] + public void AppendFingerprintPattern_AppendsPattern_AtTheEndOfTheFileName() + { + // Arrange + var relativePath = Path.Combine("folder", "test.txt"); + var expected = Path.Combine("folder", "test#[.{fingerprint}]?.txt"); + + // Act + var result = new FingerprintPatternMatcher(_log, []).AppendFingerprintPattern(CreateMatchContext(relativePath), "Identity"); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void AppendFingerprintPattern_AppendsPattern_AtTheEndOfTheFileName_WhenFileNameContainsDots() + { + // Arrange + var relativePath = Path.Combine("folder", "test.v1.txt"); + var expected = Path.Combine("folder", "test.v1#[.{fingerprint}]?.txt"); + // Act + var result = new FingerprintPatternMatcher(_log, []).AppendFingerprintPattern(CreateMatchContext(relativePath), "Identity"); + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void AppendFingerprintPattern_AppendsPattern_AtTheEndOfTheFileName_WhenFileDoesNotHaveExtension() + { + // Arrange + var relativePath = Path.Combine("folder", "README"); + var expected = Path.Combine("folder", "README#[.{fingerprint}]?"); + // Act + var result = new FingerprintPatternMatcher(_log, []).AppendFingerprintPattern(CreateMatchContext(relativePath), "Identity"); + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void AppendFingerprintPattern_AppendsPattern_AtTheRightLocation_WhenACustomPatternIsProvided() + { + // Arrange + var relativePath = Path.Combine("folder", "test.bundle.scp.css"); + var expected = Path.Combine("folder", "test#[.{fingerprint}]!.bundle.scp.css"); + + // Act + var result = new FingerprintPatternMatcher( + _log, + [new TaskItem("ScopedCSS", new Dictionary { ["Pattern"] = "*.bundle.scp.css", ["Expression"] = "#[.{fingerprint}]!" })]) + .AppendFingerprintPattern(CreateMatchContext(relativePath), "Identity"); + + // Assert + Assert.Equal(expected, result); + } + + private StaticWebAssetGlobMatcher.MatchContext CreateMatchContext(string path) + { + var context = new StaticWebAssetGlobMatcher.MatchContext(); + context.SetPathAndReinitialize(path); + return context; + } + + private class TestTaskLoggingHelper : TaskLoggingHelper + { + public TestTaskLoggingHelper() : base(new TestTask()) + { + } + + private class TestTask : ITask + { + public IBuildEngine BuildEngine { get; set; } = new TestBuildEngine(); + public ITaskHost HostObject { get; set; } = new TestTaskHost(); + + public bool Execute() => true; + } + + private class TestBuildEngine : IBuildEngine + { + public bool ContinueOnError => true; + + public int LineNumberOfTaskNode => 0; + + public int ColumnNumberOfTaskNode => 0; + + public string ProjectFileOfTaskNode => "test.csproj"; + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => true; + + public void LogCustomEvent(CustomBuildEventArgs e) { } + public void LogErrorEvent(BuildErrorEventArgs e) { } + public void LogMessageEvent(BuildMessageEventArgs e) { } + public void LogWarningEvent(BuildWarningEventArgs e) { } + } + + private class TestTaskHost : ITaskHost + { + public object HostObject { get; set; } = new object(); + } + } + +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsManifestTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsManifestTest.cs index 8eff56c75c29..c02b0b76d9d8 100644 --- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsManifestTest.cs +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/GenerateStaticWebAssetEndpointsManifestTest.cs @@ -83,7 +83,7 @@ public void GeneratesManifest_ForEndpointsWithTokens() }, new() { Name = "Content-Type", - Value = "application/javascript" + Value = "text/javascript" }, new() { Name = "ETag", @@ -167,7 +167,7 @@ public void GeneratesManifest_ForEndpointsWithTokens() }, new() { Name = "Content-Type", - Value = "application/javascript" + Value = "text/javascript" }, new() { Name = "ETag", @@ -237,12 +237,7 @@ private StaticWebAssetEndpoint[] CreateEndpoints(StaticWebAsset[] assets) { CandidateAssets = assets.Select(a => a.ToTaskItem()).ToArray(), ExistingEndpoints = [], - ContentTypeMappings = new TaskItem[] - { - CreateContentMapping("*.html", "text/html"), - CreateContentMapping("*.js", "application/javascript"), - CreateContentMapping("*.css", "text/css") - } + ContentTypeMappings = [] }; defineStaticWebAssetEndpoints.BuildEngine = Mock.Of(); defineStaticWebAssetEndpoints.TestLengthResolver = name => 10; @@ -252,16 +247,6 @@ private StaticWebAssetEndpoint[] CreateEndpoints(StaticWebAsset[] assets) return StaticWebAssetEndpoint.FromItemGroup(defineStaticWebAssetEndpoints.Endpoints); } - private static TaskItem CreateContentMapping(string pattern, string contentType) - { - return new TaskItem(contentType, new Dictionary - { - { "Pattern", pattern }, - { "Priority", "0" } - }); - } - - private static StaticWebAsset CreateAsset( string itemSpec, string sourceId = "MyApp", diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/PathTokenizerTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/PathTokenizerTest.cs new file mode 100644 index 000000000000..ed77b13265c1 --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/PathTokenizerTest.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks.Test; + +public class PathTokenizerTest +{ + [Fact] + public void RootSeparator_ProducesEmptySegment() + { + var path = "/a/b/c"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("a", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void NonRootSeparator_ProducesInitialSegment() + { + var path = "a/b/c"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("a", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void NonRootSeparator_MatchesMultipleCharacters() + { + var path = "aa/b/c"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void NonRootSeparator_HandlesConsecutivePathSeparators() + { + var path = "aa//b/c"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void NonRootSeparator_HandlesFinalPathSeparator() + { + var path = "aa/b/c/"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void NonRootSeparator_HandlesAlternativePathSeparators() + { + var path = "aa\\b\\c\\"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void NonRootSeparator_HandlesMixedPathSeparators() + { + var path = "aa/b\\c/"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void Ignores_EmpySegments() + { + var path = "aa//b//c"; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void Ignores_DotSegments() + { + var path = "./aa/./b/./c/."; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } + + [Fact] + public void Ignores_DotDotSegments() + { + var path = "../aa/../b/../c/.."; + var tokenizer = new PathTokenizer(path.AsMemory().Span); + var segments = new List(); + var collection = tokenizer.Fill(segments); + Assert.Equal("aa", collection[0]); + Assert.Equal("b", collection[1]); + Assert.Equal("c", collection[2]); + } +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.Compatibility.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.Compatibility.cs new file mode 100644 index 000000000000..f68ea9169c4f --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.Compatibility.cs @@ -0,0 +1,302 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks.Test; + +public partial class StaticWebAssetGlobMatcherTest +{ + [Fact] + public void MatchingFileIsFound() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("alpha.txt"); + var globMatcher = matcher.Build(); + + var match = globMatcher.Match("alpha.txt"); + Assert.True(match.IsMatch); + Assert.Equal("alpha.txt", match.Pattern); + } + + [Fact] + public void MismatchedFileIsIgnored() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("alpha.txt"); + var globMatcher = matcher.Build(); + + var match = globMatcher.Match("omega.txt"); + Assert.False(match.IsMatch); + } + + [Fact] + public void FolderNamesAreTraversed() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("beta/alpha.txt"); + var globMatcher = matcher.Build(); + + var match = globMatcher.Match("beta/alpha.txt"); + Assert.True(match.IsMatch); + Assert.Equal("beta/alpha.txt", match.Pattern); + } + + [Theory] + [InlineData(@"beta/alpha.txt", @"beta/alpha.txt")] + [InlineData(@"beta\alpha.txt", @"beta/alpha.txt")] + [InlineData(@"beta/alpha.txt", @"beta\alpha.txt")] + [InlineData(@"beta\alpha.txt", @"beta\alpha.txt")] + [InlineData(@"\beta\alpha.txt", @"beta/alpha.txt")] + public void SlashPolarityIsIgnored(string includePattern, string filePath) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(includePattern); + var globMatcher = matcher.Build(); + + var match = globMatcher.Match(filePath); + Assert.True(match.IsMatch); + //Assert.Equal("beta/alpha.txt", match.Pattern); + } + + [Theory] + [InlineData(@"alpha.*", new[] { "alpha.txt" })] + [InlineData(@"*", new[] { "alpha.txt", "beta.txt", "gamma.dat" })] + [InlineData(@"*et*", new[] { "beta.txt" })] + [InlineData(@"*.*", new[] { "alpha.txt", "beta.txt", "gamma.dat" })] + [InlineData(@"b*et*x", new string[0])] + [InlineData(@"*.txt", new[] { "alpha.txt", "beta.txt" })] + [InlineData(@"b*et*t", new[] { "beta.txt" })] + public void CanPatternMatch(string includes, string[] expected) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(includes); + var globMatcher = matcher.Build(); + + var matches = new List { "alpha.txt", "beta.txt", "gamma.dat" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(expected, matches); + } + + [Theory] + [InlineData(@"12345*5678", new string[0])] + [InlineData(@"1234*5678", new[] { "12345678" })] + [InlineData(@"12*23*", new string[0])] + [InlineData(@"12*3456*78", new[] { "12345678" })] + [InlineData(@"*45*56", new string[0])] + [InlineData(@"*67*78", new string[0])] + public void PatternBeginAndEndCantOverlap(string includes, string[] expected) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(includes); + var globMatcher = matcher.Build(); + + var matches = new List { "12345678" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(expected, matches); + } + + [Theory] + [InlineData(@"*alpha*/*", new[] { "alpha/hello.txt" })] + [InlineData(@"/*/*", new[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"*/*", new[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"/*.*/*", new string[] { })] + [InlineData(@"*.*/*", new string[] { })] + [InlineData(@"/*mm*/*", new[] { "gamma/hello.txt" })] + [InlineData(@"*mm*/*", new[] { "gamma/hello.txt" })] + [InlineData(@"/*alpha*/*", new[] { "alpha/hello.txt" })] + public void PatternMatchingWorksInFolders(string includes, string[] expected) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(includes); + var globMatcher = matcher.Build(); + + var matches = new List { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(expected, matches); + } + + [Theory] + [InlineData(@"", new string[] { })] + [InlineData(@"./", new string[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"./alpha/hello.txt", new string[] { "alpha/hello.txt" })] + [InlineData(@"./**/hello.txt", new string[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"././**/hello.txt", new string[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"././**/./hello.txt", new string[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"././**/./**/hello.txt", new string[] { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" })] + [InlineData(@"./*mm*/hello.txt", new string[] { "gamma/hello.txt" })] + [InlineData(@"./*mm*/*", new string[] { "gamma/hello.txt" })] + public void PatternMatchingCurrent(string includePattern, string[] matchesExpected) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(includePattern); + var globMatcher = matcher.Build(); + + var matches = new List { "alpha/hello.txt", "beta/hello.txt", "gamma/hello.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(matchesExpected, matches); + } + + [Fact] + public void StarDotStarIsSameAsStar() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*.*"); + var globMatcher = matcher.Build(); + + var matches = new List { "alpha.txt", "alpha.", ".txt", ".", "alpha", "txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "alpha.txt", "alpha.", ".txt" }, matches); + } + + [Fact] + public void IncompletePatternsDoNotInclude() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*/*.txt"); + var globMatcher = matcher.Build(); + + var matches = new List { "one/x.txt", "two/x.txt", "x.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "one/x.txt", "two/x.txt" }, matches); + } + + [Fact] + public void IncompletePatternsDoNotExclude() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*/*.txt"); + matcher.AddExcludePatterns("one/hello.txt"); + var globMatcher = matcher.Build(); + + var matches = new List { "one/x.txt", "two/x.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "one/x.txt", "two/x.txt" }, matches); + } + + [Fact] + public void TrailingRecursiveWildcardMatchesAllFiles() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("one/**"); + var globMatcher = matcher.Build(); + + var matches = new List { "one/x.txt", "two/x.txt", "one/x/y.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "one/x.txt", "one/x/y.txt" }, matches); + } + + [Fact] + public void LeadingRecursiveWildcardMatchesAllLeadingPaths() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("**/*.cs"); + var globMatcher = matcher.Build(); + + var matches = new List { "one/x.cs", "two/x.cs", "one/two/x.cs", "x.cs", "one/x.txt", "two/x.txt", "one/two/x.txt", "x.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "one/x.cs", "two/x.cs", "one/two/x.cs", "x.cs" }, matches); + } + + [Fact] + public void InnerRecursiveWildcardMustStartWithAndEndWith() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("one/**/*.cs"); + var globMatcher = matcher.Build(); + + var matches = new List { "one/x.cs", "two/x.cs", "one/two/x.cs", "x.cs", "one/x.txt", "two/x.txt", "one/two/x.txt", "x.txt" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "one/x.cs", "one/two/x.cs" }, matches); + } + + [Fact] + public void ExcludeMayEndInDirectoryName() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*.cs", "*/*.cs", "*/*/*.cs"); + matcher.AddExcludePatterns("bin/", "one/two/"); + var globMatcher = matcher.Build(); + + var matches = new List { "one/x.cs", "two/x.cs", "one/two/x.cs", "x.cs", "bin/x.cs", "bin/two/x.cs" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "one/x.cs", "two/x.cs", "x.cs" }, matches); + } + + [Fact] + public void RecursiveWildcardSurroundingContainsWith() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("**/x/**"); + var globMatcher = matcher.Build(); + + var matches = new List { "x/1", "1/x/2", "1/x", "x", "1", "1/2" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "x/1", "1/x/2", "1/x", "x" }, matches); + } + + [Fact] + public void SequentialFoldersMayBeRequired() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/b/**/1/2/**/2/3/**"); + var globMatcher = matcher.Build(); + + var matches = new List { "1/2/2/3/x", "1/2/3/y", "a/1/2/4/2/3/b", "a/2/3/1/2/b", "a/b/1/2/2/3/x", "a/b/1/2/3/y", "a/b/a/1/2/4/2/3/b", "a/b/a/2/3/1/2/b" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "a/b/1/2/2/3/x", "a/b/a/1/2/4/2/3/b" }, matches); + } + + [Fact] + public void RecursiveAloneIncludesEverything() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("**"); + var globMatcher = matcher.Build(); + + var matches = new List { "1/2/2/3/x", "1/2/3/y" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "1/2/2/3/x", "1/2/3/y" }, matches); + } + + [Fact] + public void ExcludeCanHaveSurroundingRecursiveWildcards() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("**"); + matcher.AddExcludePatterns("**/x/**"); + var globMatcher = matcher.Build(); + + var matches = new List { "x/1", "1/x/2", "1/x", "x", "1", "1/2" } + .Where(file => globMatcher.Match(file).IsMatch) + .ToArray(); + + Assert.Equal(new[] { "1", "1/2" }, matches); + } +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.cs new file mode 100644 index 000000000000..f8061c5d6ba1 --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/Globbing/StaticWebAssetGlobMatcherTest.cs @@ -0,0 +1,388 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks.Test; + +// Set of things to test: +// Literals 'a' +// Multiple literals 'a/b' +// Extensions '*.a' +// Longer extensions first '*.a', '*.b.a' +// Extensions at the beginning '*.a/b' +// Extensions at the end 'a/*.b' +// Extensions in the middle 'a/*.b/c' +// Wildcard '*' +// Wildcard at the beginning '*/a' +// Wildcard at the end 'a/*' +// Wildcard in the middle 'a/*/c' +// Recursive wildcard '**' +// Recursive wildcard at the beginning '**/a' +// Recursive wildcard at the end 'a/**' +// Recursive wildcard in the middle 'a/**/c' +public partial class StaticWebAssetGlobMatcherTest +{ + [Fact] + public void CanMatchLiterals() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a"); + var globMatcher = matcher.Build(); + + var match = globMatcher.Match("a"); + Assert.True(match.IsMatch); + Assert.Equal("a", match.Pattern); + Assert.Equal("a", match.Stem); + } + + [Fact] + public void CanMatchMultipleLiterals() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/b"); + var globMatcher = matcher.Build(); + + var match = globMatcher.Match("a/b"); + Assert.True(match.IsMatch); + Assert.Equal("a/b", match.Pattern); + Assert.Equal("b", match.Stem); + } + + [Fact] + public void CanMatchExtensions() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*.a"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a.a"); + Assert.True(match.IsMatch); + Assert.Equal("*.a", match.Pattern); + Assert.Equal("a.a", match.Stem); + } + + [Fact] + public void MatchesLongerExtensionsFirst() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*.a", "*.b.a"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("c.b.a"); + Assert.True(match.IsMatch); + Assert.Equal("*.b.a", match.Pattern); + Assert.Equal("c.b.a", match.Stem); + } + + [Fact] + public void CanMatchExtensionsAtTheBeginning() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*.a/b"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("c.a/b"); + Assert.True(match.IsMatch); + Assert.Equal("*.a/b", match.Pattern); + Assert.Equal("b", match.Stem); + } + + [Fact] + public void CanMatchExtensionsAtTheEnd() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/*.b"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/c.b"); + Assert.True(match.IsMatch); + Assert.Equal("a/*.b", match.Pattern); + Assert.Equal("c.b", match.Stem); + } + + [Fact] + public void CanMatchExtensionsInMiddle() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/*.b/c"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/d.b/c"); + Assert.True(match.IsMatch); + Assert.Equal("a/*.b/c", match.Pattern); + Assert.Equal("c", match.Stem); + } + + [Theory] + [InlineData("a*")] + [InlineData("*a")] + [InlineData("?")] + [InlineData("*?")] + [InlineData("?*")] + [InlineData("**a")] + [InlineData("a**")] + [InlineData("**?")] + [InlineData("?**")] + public void CanMatchComplexSegments(string pattern) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(pattern); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a"); + Assert.True(match.IsMatch); + Assert.Equal(pattern, match.Pattern); + Assert.Equal("a", match.Stem); + } + + [Theory] + [InlineData("a?", "aa", true)] + [InlineData("a?", "a", false)] + [InlineData("a?", "aaa", false)] + [InlineData("?a", "aa", true)] + [InlineData("?a", "a", false)] + [InlineData("?a", "aaa", false)] + [InlineData("a?a", "aaa", true)] + [InlineData("a?a", "aba", true)] + [InlineData("a?a", "abaa", false)] + [InlineData("a?a", "ab", false)] + public void QuestionMarksMatchSingleCharacter(string pattern, string input, bool expectedMatchResult) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(pattern); + var globMatcher = matcher.Build(); + var match = globMatcher.Match(input); + Assert.Equal(expectedMatchResult, match.IsMatch); + if(expectedMatchResult) + { + Assert.Equal(pattern, match.Pattern); + Assert.Equal(input, match.Stem); + } + else + { + Assert.Null(match.Pattern); + Assert.Null(match.Stem); + } + } + + [Theory] + [InlineData("a??", "aaa", true)] + [InlineData("a??", "aa", false)] + [InlineData("a??", "aaaa", false)] + [InlineData("?a?", "aaa", true)] + [InlineData("?a?", "aa", false)] + [InlineData("?a?", "aaaa", false)] + [InlineData("??a", "aaa", true)] + [InlineData("??a", "aa", false)] + [InlineData("??a", "aaaa", false)] + [InlineData("a??a", "aaaa", true)] + [InlineData("a??a", "aaba", true)] + [InlineData("a??a", "aabaa", false)] + [InlineData("a??a", "aba", false)] + public void MultipleQuestionMarksMatchExactlyTheNumberOfCharacters(string pattern, string input, bool expectedMatchResult) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(pattern); + var globMatcher = matcher.Build(); + var match = globMatcher.Match(input); + Assert.Equal(expectedMatchResult, match.IsMatch); + if (expectedMatchResult) + { + Assert.Equal(pattern, match.Pattern); + Assert.Equal(input, match.Stem); + } + else + { + Assert.Null(match.Pattern); + Assert.Null(match.Stem); + } + } + + [Theory] + [InlineData("a*", "a", true)] + [InlineData("a*", "aa", true)] + [InlineData("a*", "aaa", true)] + [InlineData("a*", "aaaa", true)] + [InlineData("a*", "aaaaa", true)] + [InlineData("a*", "aaaaaa", true)] + [InlineData("*a", "a", true)] + [InlineData("*a", "aa", true)] + [InlineData("*a", "aaa", true)] + [InlineData("*a", "aaaa", true)] + [InlineData("*a", "aaaaa", true)] + [InlineData("a*a", "a", false)] + [InlineData("a*a", "aa", true)] + [InlineData("a*a", "aaa", true)] + [InlineData("a*a", "aaaaa", true)] + [InlineData("a*a", "aaaaaa", true)] + [InlineData("a*a", "aba", true)] + [InlineData("a*a", "abaa", true)] + [InlineData("a*a", "abba", true)] + [InlineData("a*b", "ab", true)] + public void WildCardsMatchZeroOrMoreCharacters(string pattern, string input, bool expectedMatchResult) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(pattern); + var globMatcher = matcher.Build(); + var match = globMatcher.Match(input); + Assert.Equal(expectedMatchResult, match.IsMatch); + if (expectedMatchResult) + { + Assert.Equal(pattern, match.Pattern); + Assert.Equal(input, match.Stem); + } + else + { + Assert.Null(match.Pattern); + Assert.Null(match.Stem); + } + } + + [Theory] + [InlineData("*?a", "a", false)] + [InlineData("*?a", "aa", true)] + [InlineData("*?a", "aaa", true)] + [InlineData("*?a", "aaaa", true)] + [InlineData("*?a", "aaaaa", true)] + [InlineData("*?a", "aaaaaa", true)] + [InlineData("*??a", "aa", false)] + [InlineData("*??a", "aaa", true)] + [InlineData("*???a", "aaa", false)] + [InlineData("*???a", "aaaa", true)] + [InlineData("*????a", "aaaa", false)] + [InlineData("*????a", "aaaaa", true)] + [InlineData("*?????a", "aaaaa", false)] + [InlineData("*?????a", "aaaaaa", true)] + [InlineData("*??????a", "aaaaaa", false)] + [InlineData("*??????a", "aaaaaaa", true)] + [InlineData("a*?", "a", false)] + [InlineData("a*?", "aa", true)] + [InlineData("a*?", "aaa", true)] + [InlineData("a*?", "aaaa", true)] + [InlineData("a*?", "aaaaa", true)] + [InlineData("a*?", "aaaaaa", true)] + [InlineData("a*??", "aa", false)] + [InlineData("a*??", "aaa", true)] + [InlineData("a*???", "aaa", false)] + [InlineData("a*???", "aaaa", true)] + [InlineData("a*????", "aaaa", false)] + [InlineData("a*????", "aaaaa", true)] + [InlineData("a*?????", "aaaaa", false)] + [InlineData("a*?????", "aaaaaa", true)] + [InlineData("a*??????", "aaaaaa", false)] + [InlineData("a*??????", "aaaaaaa", true)] + + public void SingleWildcardPrecededOrSucceededByQuestionMarkRequireMinimumNumberOfCharacters(string pattern, string input, bool expectedMatchResult) + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns(pattern); + var globMatcher = matcher.Build(); + var match = globMatcher.Match(input); + Assert.Equal(expectedMatchResult, match.IsMatch); + if (expectedMatchResult) + { + Assert.Equal(pattern, match.Pattern); + Assert.Equal(input, match.Stem); + } + else + { + Assert.Null(match.Pattern); + Assert.Null(match.Stem); + } + } + + [Fact] + public void CanMatchWildCard() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a"); + Assert.True(match.IsMatch); + Assert.Equal("*", match.Pattern); + Assert.Equal("a", match.Stem); + } + + [Fact] + public void CanMatchWildCardAtTheBeginning() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("*/a"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("c/a"); + Assert.True(match.IsMatch); + Assert.Equal("*/a", match.Pattern); + Assert.Equal("a", match.Stem); + } + + [Fact] + public void CanMatchWildCardAtTheEnd() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/*"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/c"); + Assert.True(match.IsMatch); + Assert.Equal("a/*", match.Pattern); + Assert.Equal("c", match.Stem); + } + + [Fact] + public void CanMatchWildCardInMiddle() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/*/c"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/b/c"); + Assert.True(match.IsMatch); + Assert.Equal("a/*/c", match.Pattern); + Assert.Equal("c", match.Stem); + } + + [Fact] + public void CanMatchRecursiveWildCard() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("**"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/b/c"); + Assert.True(match.IsMatch); + Assert.Equal("**", match.Pattern); + Assert.Equal("a/b/c", match.Stem); + } + + [Fact] + public void CanMatchRecursiveWildCardAtTheBeginning() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("**/a"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("c/b/a"); + Assert.True(match.IsMatch); + Assert.Equal("**/a", match.Pattern); + Assert.Equal("c/b/a", match.Stem); + } + + [Fact] + public void CanMatchRecursiveWildCardAtTheEnd() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/**"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/b/c"); + Assert.True(match.IsMatch); + Assert.Equal("a/**", match.Pattern); + Assert.Equal("b/c", match.Stem); + } + + [Fact] + public void CanMatchRecursiveWildCardInMiddle() + { + var matcher = new StaticWebAssetGlobMatcherBuilder(); + matcher.AddIncludePatterns("a/**/c"); + var globMatcher = matcher.Build(); + var match = globMatcher.Match("a/b/c"); + Assert.True(match.IsMatch); + Assert.Equal("a/**/c", match.Pattern); + Assert.Equal("b/c", match.Stem); + } +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/StaticWebAssetPathPatternTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/StaticWebAssetPathPatternTest.cs index 2740505b81ca..2ddee33eee9f 100644 --- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/StaticWebAssetPathPatternTest.cs +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/StaticWebAssetPathPatternTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using Microsoft.AspNetCore.StaticWebAssets.Tasks; namespace Microsoft.NET.Sdk.Razor.Tests.StaticWebAssets; @@ -15,7 +16,7 @@ public void CanParse_PathWithNoExpressions() { Segments = [ - new (){ Parts = [ new() { Name = "css/site.css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "css/site.css".AsMemory(), IsLiteral = true }] } ] }; Assert.Equal(expected, pattern); @@ -29,9 +30,9 @@ public void CanParse_ComplexFingerprintExpression_Middle() { Segments = [ - new (){ Parts = [ new() { Name = "css/site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "css/site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -46,8 +47,8 @@ public void CanParse_ComplexFingerprintExpression_Start() { Segments = [ - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -62,8 +63,8 @@ public void CanParse_ComplexFingerprintExpression_End() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] } ] }; @@ -78,7 +79,7 @@ public void CanParse_ComplexFingerprintExpression_Only() { Segments = [ - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] } + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] } ] }; @@ -93,11 +94,11 @@ public void CanParse_ComplexFingerprintExpression_Multiple() { Segments = [ - new (){ Parts = [ new() { Name = "css/site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = "-", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "version", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "css/site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = "-".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "version".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -112,10 +113,10 @@ public void CanParse_ComplexFingerprintExpression_ConsecutiveExpressions() { Segments = [ - new (){ Parts = [ new() { Name = "css/site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "version", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "css/site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "version".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -130,8 +131,8 @@ public void CanParse_SimpleFingerprintExpression_Start() { Segments = [ - new (){ Parts = [ new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -146,9 +147,9 @@ public void CanParse_SimpleFingerprintExpression_Middle() { Segments = [ - new (){ Parts = [ new() { Name = "css/site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "css/site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -163,8 +164,8 @@ public void CanParse_SimpleFingerprintExpression_End() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = "fingerprint", IsLiteral = false }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] } ] }; @@ -179,7 +180,7 @@ public void CanParse_SimpleFingerprintExpression_Only() { Segments = [ - new (){ Parts = [ new() { Name = "fingerprint", IsLiteral = false }] } + new (){ Parts = [ new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] } ] }; @@ -194,7 +195,7 @@ public void CanParse_SimpleFingerprintExpression_WithEmbeddedValues() { Segments = [ - new (){ Parts = [ new() { Name = "fingerprint", Value = "value", IsLiteral = false }] } + new (){ Parts = [ new() { Name = "fingerprint".AsMemory(), Value = "value".AsMemory(), IsLiteral = false }] } ] }; @@ -209,14 +210,14 @@ public void CanParse_ComplexExpression_MultipleVariables() { Segments = [ - new (){ Parts = [ new() { Name = "css/site", IsLiteral = true }] }, + new (){ Parts = [ new() { Name = "css/site".AsMemory(), IsLiteral = true }] }, new (){ Parts = [ - new() { Name = ".", IsLiteral = true }, - new() { Name = "fingerprint", IsLiteral = false }, - new() { Name = "-", IsLiteral = true }, - new() { Name = "version", IsLiteral = false } + new() { Name = ".".AsMemory(), IsLiteral = true }, + new() { Name = "fingerprint".AsMemory(), IsLiteral = false }, + new() { Name = "-".AsMemory(), IsLiteral = true }, + new() { Name = "version".AsMemory(), IsLiteral = false } ] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -231,13 +232,13 @@ public void CanParse_ComplexExpression_MultipleConsecutiveVariables() { Segments = [ - new (){ Parts = [ new() { Name = "css/site", IsLiteral = true }] }, + new (){ Parts = [ new() { Name = "css/site".AsMemory(), IsLiteral = true }] }, new (){ Parts = [ - new() { Name = ".", IsLiteral = true }, - new() { Name = "fingerprint", IsLiteral = false }, - new() { Name = "version", IsLiteral = false } + new() { Name = ".".AsMemory(), IsLiteral = true }, + new() { Name = "fingerprint".AsMemory(), IsLiteral = false }, + new() { Name = "version".AsMemory(), IsLiteral = false } ] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }; @@ -252,8 +253,8 @@ public void CanParse_ComplexExpression_StartsWithVariable() { Segments = [ - new (){ Parts = [ new() { Name = "fingerprint", IsLiteral = false }, new() { Name = ".", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = "css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "fingerprint".AsMemory(), IsLiteral = false }, new() { Name = ".".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = "css".AsMemory(), IsLiteral = true }] } ] }; @@ -268,8 +269,8 @@ public void CanParse_OptionalExpressions_End() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true } ] }; @@ -284,8 +285,8 @@ public void CanParse_OptionalPreferredExpressions() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true, IsPreferred = true } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true, IsPreferred = true } ] }; @@ -300,8 +301,8 @@ public void CanParse_OptionalExpressions_Start() { Segments = [ - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true }, - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true }, + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }] }; @@ -316,9 +317,9 @@ public void CanParse_OptionalExpressions_Middle() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true }, - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true }, + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }] }; @@ -333,7 +334,7 @@ public void CanParse_OptionalExpressions_Only() { Segments = [ - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true } + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true } ] }; @@ -348,9 +349,9 @@ public void CanParse_MultipleOptionalExpressions() { Segments = [ - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true }, - new (){ Parts = [ new() { Name = "site", IsLiteral = true }], IsOptional = false }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "version", IsLiteral = false }], IsOptional = true } + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true }, + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }], IsOptional = false }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "version".AsMemory(), IsLiteral = false }], IsOptional = true } ] }; @@ -365,8 +366,8 @@ public void CanParse_ConsecutiveOptionalExpressions() { Segments = [ - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }], IsOptional = true }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "version", IsLiteral = false }], IsOptional = true } + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }], IsOptional = true }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "version".AsMemory(), IsLiteral = false }], IsOptional = true } ] }; @@ -650,17 +651,17 @@ public void CanExpandRoutes_SingleOptionalExpression() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }, new StaticWebAssetPathPattern("site#[.{fingerprint}].css") { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] } }; @@ -680,36 +681,36 @@ public void CanExpandRoutes_MultipleOptionalExpressions() { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }, new StaticWebAssetPathPattern("site#[.{fingerprint}].css") { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }, new StaticWebAssetPathPattern("site#[.{version}].css") { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "version", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "version".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] }, new StaticWebAssetPathPattern("site#[.{fingerprint}]#[.{version}].css") { Segments = [ - new (){ Parts = [ new() { Name = "site", IsLiteral = true }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "fingerprint", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".", IsLiteral = true }, new() { Name = "version", IsLiteral = false }] }, - new (){ Parts = [ new() { Name = ".css", IsLiteral = true }] } + new (){ Parts = [ new() { Name = "site".AsMemory(), IsLiteral = true }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "fingerprint".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".".AsMemory(), IsLiteral = true }, new() { Name = "version".AsMemory(), IsLiteral = false }] }, + new (){ Parts = [ new() { Name = ".css".AsMemory(), IsLiteral = true }] } ] } };