File: StaticWebAssetsBaselineFactory.cs
Web Access
Project: ..\..\..\test\Microsoft.NET.Sdk.BlazorWebAssembly.Tests\Microsoft.NET.Sdk.BlazorWebAssembly.Tests.csproj (Microsoft.NET.Sdk.BlazorWebAssembly.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable disable
 
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticWebAssets.Tasks;
using NuGet.Frameworks;
using NuGet.ProjectModel;
 
namespace Microsoft.NET.Sdk.StaticWebAssets.Tests;
public partial class StaticWebAssetsBaselineFactory
{
    [GeneratedRegex("""(.*\.)([0123456789abcdefghijklmnopqrstuvwxyz]{10})(\.bundle\.scp\.css)((?:\.gz)|(?:\.br))?$""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex ScopedProjectBundleRegex();
 
    [GeneratedRegex("""(.*\.)([0123456789abcdefghijklmnopqrstuvwxyz]{10})(\.styles\.css)((?:\.gz)|(?:\.br))?$""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex ScopedAppBundleRegex();
 
    [GeneratedRegex("""fingerprint-site(\.)([0123456789abcdefghijklmnopqrstuvwxyz]{10})(\.css)((?:\.gz)|(?:\.br))?$""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex FingerprintedSiteCssRegex();
 
    [GeneratedRegex("""(?:#\[\.{fingerprint=[0123456789abcdefghijklmnopqrstuvwxyz]{10}\}](\?|\!)?)""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex EmbeddedFingerprintExpression();
 
    [GeneratedRegex("""(.*\.)([0123456789abcdefghijklmnopqrstuvwxyz]{10})(\.lib\.module\.js)((?:\.gz)|(?:\.br))?$""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex JSInitializerRegex();
 
    [GeneratedRegex("""(.*\.)([0123456789abcdefghijklmnopqrstuvwxyz]{10})(\.modules\.json)((?:\.gz)|(?:\.br))?$""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex JSModuleManifestRegex();
 
    private static readonly IList<(Regex expression, string replacement)> WellKnownFileNamePatternsAndReplacements =
    [
        (ScopedProjectBundleRegex(),"$1__fingerprint__$3$4"),
        (ScopedAppBundleRegex(),"$1__fingerprint__$3$4"),
        (JSInitializerRegex(), "$1__fingerprint__$3$4"),
        (JSModuleManifestRegex(), "$1__fingerprint__$3$4"),
        (EmbeddedFingerprintExpression(), "#[.{fingerprint=__fingerprint__}]$1"),
        (FingerprintedSiteCssRegex(), "fingerprint-site$1__fingerprint__$3$4"),
    ];
 
    public static StaticWebAssetsBaselineFactory Instance { get; } = new();
 
    public IList<string> KnownExtensions { get; } =
    [
        // Keep this list of most specific to less specific
        ".dll.gz",
        ".dll.br",
        ".dll",
        ".wasm.gz",
        ".wasm.br",
        ".wasm",
        ".js.gz",
        ".js.br",
        ".js",
        ".html",
        ".pdb",
    ];
 
    public IList<string> KnownFilePrefixesWithHashOrVersion { get; } =
    [
        "blazor.web.",
        "blazor.server",
        "dotnet.runtime",
        "dotnet.native",
        "dotnet.boot",
        "dotnet"
    ];
 
    public void ToTemplate(
        StaticWebAssetsManifest manifest,
        string projectRoot,
        string restorePath,
        string runtimeIdentifier)
    {
        manifest.Hash = "__hash__";
        var assetsByIdentity = manifest.Assets.ToDictionary(a => a.Identity);
        var endpointsByAssetFile = manifest.Endpoints.GroupBy(e => e.AssetFile).ToDictionary(g => g.Key, g => g.ToArray());
        foreach (var asset in manifest.Assets)
        {
            var relatedEndpoints = endpointsByAssetFile.GetValueOrDefault(asset.Identity);
            TemplatizeAsset(projectRoot, restorePath, runtimeIdentifier, asset);
            foreach (var endpoint in relatedEndpoints ?? [])
            {
                endpoint.AssetFile = asset.Identity;
            }
            if (asset.AssetTraitName == "Content-Encoding")
            {
                var basePath = asset.BasePath.Replace('/', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar);
                var relativePath = asset.RelativePath.Replace('/', Path.DirectorySeparatorChar);
                var identity = asset.Identity.Replace('\\', Path.DirectorySeparatorChar);
                var originalItemSpec = asset.OriginalItemSpec.Replace('\\', Path.DirectorySeparatorChar);
 
                asset.Identity = Path.Combine(Path.GetDirectoryName(identity), basePath, relativePath);
                asset.Identity = asset.Identity.Replace(Path.DirectorySeparatorChar, '\\');
                foreach (var endpoint in relatedEndpoints ?? [])
                {
                    endpoint.AssetFile = asset.Identity;
                }
                asset.OriginalItemSpec = Path.Combine(Path.GetDirectoryName(originalItemSpec), basePath, relativePath);
                asset.OriginalItemSpec = asset.OriginalItemSpec.Replace(Path.DirectorySeparatorChar, '\\');
            }
            else if ((asset.Identity.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) || asset.Identity.EndsWith(".br", StringComparison.OrdinalIgnoreCase))
                && asset.AssetTraitName == "" && asset.RelatedAsset == "")
            {
                // Old .NET 5.0 implementation
                var identity = asset.Identity.Replace('\\', Path.DirectorySeparatorChar);
                var originalItemSpec = asset.OriginalItemSpec.Replace('\\', Path.DirectorySeparatorChar);
 
                asset.Identity = Path.Combine(Path.GetDirectoryName(identity), Path.GetFileName(originalItemSpec) + Path.GetExtension(identity))
                    .Replace(Path.DirectorySeparatorChar, '\\');
            }
        }
 
        foreach (var endpoint in manifest.Endpoints)
        {
            for (var i = 0; i < endpoint.ResponseHeaders.Length; i++)
            {
                ref var header = ref endpoint.ResponseHeaders[i];
                switch (header.Name)
                {
                    case "Content-Length":
                        header.Value = "__content-length__";
                        break;
                    case "ETag":
                        header.Value = "__etag__";
                        break;
                    case "Last-Modified":
                        header.Value = "__last-modified__";
                        break;
                    case "Link":
                        var cleaned = new List<string>();
                        var values = header.Value.Split(',').Select(v => v.Trim());
                        foreach (var value in values)
                        {
                            var segments = value.Split(';').Select(v => v.Trim()).ToArray();
                            var file = segments[0][1..^1];
                            segments[0] = $"<{ReplaceFileName(file).Replace('\\', '/')}>";
                            cleaned.Add(string.Join("; ", segments));
                        }
                        header.Value = string.Join(", ", cleaned);
 
                        break;
                    default:
                        break;
                }
            }
 
            for (var i = 0; i < endpoint.EndpointProperties.Length; i++)
            {
                ref var property = ref endpoint.EndpointProperties[i];
                switch (property.Name)
                {
                    case "fingerprint":
                        property.Value = "__fingerprint__";
                        endpoint.Route = endpoint.Route.Replace(property.Value, $"__{property.Name}__");
                        break;
                    case "integrity":
                        property.Value = "__integrity__";
                        break;
                    case "original-resource":
                        property.Value = "__original-resource__";
                        break;
                    default:
                        break;
                }
 
                ReplaceFileName(endpoint.Route);
            }
 
            for (var i = 0; i < endpoint.Selectors.Length; i++)
            {
                ref var selector = ref endpoint.Selectors[i];
                selector.Quality = "__quality__";
            }
 
            endpoint.Route = TemplatizeFilePath(endpoint.Route, null, null, null, null, null).Replace("\\", "/");
 
            endpoint.AssetFile = TemplatizeFilePath(
                endpoint.AssetFile,
                restorePath,
                projectRoot,
                null,
                null,
                runtimeIdentifier);
        }
 
        foreach (var discovery in manifest.DiscoveryPatterns)
        {
            discovery.ContentRoot = discovery.ContentRoot.Replace(projectRoot, "${ProjectPath}", StringComparison.OrdinalIgnoreCase);
            discovery.ContentRoot = discovery.ContentRoot.Replace(Path.DirectorySeparatorChar, '\\');
 
            discovery.Name = discovery.Name.Replace(Path.DirectorySeparatorChar, '\\');
            discovery.Pattern = discovery.Pattern.Replace(Path.DirectorySeparatorChar, '\\');
        }
 
        foreach (var relatedManifest in manifest.ReferencedProjectsConfiguration)
        {
            relatedManifest.Identity = relatedManifest.Identity.Replace(projectRoot, "${ProjectPath}").Replace(Path.DirectorySeparatorChar, '\\');
        }
 
        // Sor everything now to ensure we produce stable baselines independent of the machine they were generated on.
        Array.Sort(manifest.DiscoveryPatterns, (l, r) => StringComparer.Ordinal.Compare(l.Name, r.Name));
        Array.Sort(manifest.Assets);
        foreach (var endpoint in manifest.Endpoints)
        {
            Array.Sort(endpoint.Selectors);
            Array.Sort(endpoint.EndpointProperties);
            Array.Sort(endpoint.ResponseHeaders);
        }
        Array.Sort(manifest.Endpoints);
 
        Array.Sort(manifest.ReferencedProjectsConfiguration, (l, r) => StringComparer.Ordinal.Compare(l.Identity, r.Identity));
    }
 
    private void TemplatizeAsset(string projectRoot, string restorePath, string runtimeIdentifier, StaticWebAsset asset)
    {
        asset.Identity = TemplatizeFilePath(
            asset.Identity,
            restorePath,
            projectRoot,
            null,
            null,
            runtimeIdentifier);
 
        asset.RelativePath = TemplatizeFilePath(
            asset.RelativePath,
            null,
            null,
            null,
            null,
            runtimeIdentifier).Replace('\\', '/');
 
        asset.ContentRoot = TemplatizeFilePath(
            asset.ContentRoot,
            restorePath,
            projectRoot,
            null,
            null,
            runtimeIdentifier);
 
        asset.RelatedAsset = TemplatizeFilePath(
            asset.RelatedAsset,
            restorePath,
            projectRoot,
            null,
            null,
            runtimeIdentifier);
 
        asset.OriginalItemSpec = TemplatizeFilePath(
            asset.OriginalItemSpec,
            restorePath,
            projectRoot,
            null,
            null,
            runtimeIdentifier);
 
        asset.Fingerprint = string.IsNullOrEmpty(asset.Fingerprint) ? asset.Fingerprint : "__fingerprint__";
        asset.Integrity = string.IsNullOrEmpty(asset.Integrity) ? asset.Integrity : "__integrity__";
        asset.FileLength = -1;
        asset.LastWriteTime = DateTimeOffset.MinValue;
    }
 
    internal IEnumerable<string> TemplatizeExpectedFiles(
        IEnumerable<string> files,
        string restorePath,
        string projectPath,
        string intermediateOutputPath,
        string buildOrPublishFolder)
    {
        foreach (var file in files)
        {
            var updated = TemplatizeFilePath(
                file,
                restorePath,
                projectPath,
                intermediateOutputPath,
                buildOrPublishFolder,
                null);
 
            yield return updated;
        }
    }
 
    public string TemplatizeFilePath(
        string file,
        string restorePath,
        string projectPath,
        string intermediateOutputPath,
        string buildOrPublishFolder,
        string runtimeIdentifier)
    {
        var updated = file switch
        {
            var processed when file.StartsWith('$') => processed,
            var fromBuildOrPublishPath when buildOrPublishFolder is not null && file.StartsWith(buildOrPublishFolder, StringComparison.OrdinalIgnoreCase) =>
                TemplatizeBuildOrPublishPath(buildOrPublishFolder, fromBuildOrPublishPath),
            var fromIntermediateOutputPath when intermediateOutputPath is not null && file.StartsWith(intermediateOutputPath, StringComparison.OrdinalIgnoreCase) =>
                TemplatizeIntermediatePath(intermediateOutputPath, fromIntermediateOutputPath),
            var fromPackage when restorePath is not null && file.StartsWith(restorePath, StringComparison.OrdinalIgnoreCase) =>
                TemplatizeNugetPath(restorePath, fromPackage),
            var fromProject when projectPath is not null && file.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase) =>
                TemplatizeProjectPath(projectPath, fromProject, runtimeIdentifier),
            _ =>
                ReplaceSegments(file, (i, segments) => i switch
                {
                    2 when segments[0] is "obj" or "bin" => "${Tfm}",
                    var last when i == segments.Length - 1 => RemovePossibleHash(segments[last]),
                    _ => segments[i]
                })
        };
 
        return ReplaceFileName(updated).Replace('/', '\\');
    }
 
    private static string ReplaceFileName(string path)
    {
        var directory = Path.GetDirectoryName(path);
        var fileName = Path.GetFileName(path);
        foreach (var (expression, replacement) in WellKnownFileNamePatternsAndReplacements)
        {
            if (expression.IsMatch(fileName))
            {
                fileName = expression.Replace(fileName, replacement);
                return Path.Combine(directory, fileName);
            }
        }
 
        return path;
    }
 
    private string TemplatizeBuildOrPublishPath(string outputPath, string file)
    {
        file = file.Replace(outputPath, "${OutputPath}")
            .Replace('\\', '/');
 
        file = ReplaceSegments(file, (i, segments) => i switch
        {
            _ when i == segments.Length - 1 => RemovePossibleHash(segments[i]),
            _ => segments[i],
        });
 
        return file;
    }
 
    private string TemplatizeIntermediatePath(string intermediatePath, string file)
    {
        file = file.Replace(intermediatePath, "${IntermediateOutputPath}")
            .Replace('\\', '/');
 
        file = ReplaceSegments(file, (i, segments) => i switch
        {
            3 when segments[1] is "obj" or "bin" => "${Tfm}",
            _ when i == segments.Length - 1 => RemovePossibleHash(segments[i]),
            _ => segments[i]
        });
 
        return file;
    }
 
    private string TemplatizeProjectPath(string projectPath, string file, string runtimeIdentifier)
    {
        file = file.Replace(projectPath, "${ProjectPath}")
            .Replace('\\', '/');
 
        file = ReplaceSegments(file, (i, segments) => i switch
        {
            3 when segments[1] is "obj" or "bin" => "${Tfm}",
            4 when segments[2] is "obj" or "bin" => "${Tfm}",
            4 when segments[1] is "obj" or "bin" && segments[4] == runtimeIdentifier => "${Rid}",
            5 when segments[2] is "obj" or "bin" && segments[5] == runtimeIdentifier => "${Rid}",
            _ when i == segments.Length - 1 => RemovePossibleHash(segments[i]),
            _ => segments[i]
        });
 
        return file;
    }
 
    private string TemplatizeNugetPath(string restorePath, string file)
    {
        file = file.Replace(restorePath, "${RestorePath}", StringComparison.OrdinalIgnoreCase)
            .Replace('\\', '/');
        if (file.Contains("runtimes"))
        {
            file = ReplaceSegments(file, (i, segments) => i switch
            {
                2 => "${RuntimeVersion}",
                6 when !file.Contains("native") => "${Tfm}",
                _ when i == segments.Length - 1 => RemovePossibleHash(segments[i]),
                _ => segments[i],
            });
        }
        else
        {
            file = ReplaceSegments(file, (i, segments) => i switch
            {
                2 => "${PackageVersion}",
                4 when IsFramework(segments[4]) => "${Tfm}",
                _ when i == segments.Length - 1 => RemovePossibleHash(segments[i]),
                _ => segments[i],
            });
        }
 
        return file;
 
        static bool IsFramework(string segment)
        {
            try
            {
                var tfm = NuGetFramework.ParseFolder(segment);
 
                return tfm.Framework is FrameworkConstants.FrameworkIdentifiers.NetCoreApp or
                    FrameworkConstants.FrameworkIdentifiers.NetStandard or
                    FrameworkConstants.FrameworkIdentifiers.NetCore or
                    FrameworkConstants.FrameworkIdentifiers.Net;
            }
            catch
            {
                return false;
            }
        }
    }
 
    private static string ReplaceSegments(string file, Func<int, string[], string> selector)
    {
        var segments = file.Split('\\', '/');
        var newSegments = new List<string>();
 
        // Segments have the following shape `${RestorePath}/PackageName/PackageVersion/lib/Tfm/dll`.
        // We want to replace PackageVersion and Tfm with tokens so that they do not cause issues.
        for (var i = 0; i < segments.Length; i++)
        {
            newSegments.Add(selector(i, segments));
        }
 
        return string.Join(Path.DirectorySeparatorChar, newSegments);
    }
 
    private string RemovePossibleHash(string fileNameAndExtension)
    {
        var filename = KnownFilePrefixesWithHashOrVersion.FirstOrDefault(p => fileNameAndExtension.StartsWith(p));
        if (filename != null && filename.EndsWith("."))
        {
            filename = filename[..^1];
        }
        var extension = KnownExtensions.FirstOrDefault(f => fileNameAndExtension.EndsWith(f, StringComparison.OrdinalIgnoreCase));
        if (filename != null && extension != null)
        {
            fileNameAndExtension = filename + extension;
        }
 
        return fileNameAndExtension;
    }
}