File: src\Shared\StaticWebAssets\ManifestStaticWebAssetFileProvider.cs
Web Access
Project: src\src\Hosting\Hosting\src\Microsoft.AspNetCore.Hosting.csproj (Microsoft.AspNetCore.Hosting)
// 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 System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.AspNetCore.StaticWebAssets;
 
internal sealed partial class ManifestStaticWebAssetFileProvider : IFileProvider
{
    private static readonly StringComparison _fsComparison = OperatingSystem.IsWindows() ?
        StringComparison.OrdinalIgnoreCase :
        StringComparison.Ordinal;
 
    private static readonly IEqualityComparer<IFileInfo> _nameComparer = new FileNameComparer();
 
    private readonly IFileProvider[] _fileProviders;
    private readonly StaticWebAssetNode _root;
 
    public ManifestStaticWebAssetFileProvider(StaticWebAssetManifest manifest, Func<string, IFileProvider> fileProviderFactory)
    {
        _fileProviders = new IFileProvider[manifest.ContentRoots.Length];
 
        for (int i = 0; i < manifest.ContentRoots.Length; i++)
        {
            _fileProviders[i] = fileProviderFactory(manifest.ContentRoots[i]);
        }
 
        _root = manifest.Root;
    }
 
    // For testing purposes only
    internal IFileProvider[] FileProviders => _fileProviders;
 
    public IDirectoryContents GetDirectoryContents(string subpath)
    {
        ArgumentNullException.ThrowIfNull(subpath);
 
        var segments = Normalize(subpath).Split('/', StringSplitOptions.RemoveEmptyEntries);
        var candidate = _root;
 
        // Iterate over the path segments until we reach the destination. Whenever we encounter
        // a pattern, we start tracking it as well as the content root directory. On every segment
        // we evalutate the directory to see if there is a subfolder with the current segment and
        // replace it on the dictionary if it exists or remove it if it does not.
        // When we reach our destination we enumerate the files in that folder and evalute them against
        // the pattern to compute the final list.
        HashSet<IFileInfo>? files = null;
        for (var i = 0; i < segments.Length; i++)
        {
            files = GetFilesForCandidatePatterns(segments, candidate, files);
 
            if (candidate.HasChildren() && candidate.Children.TryGetValue(segments[i], out var child))
            {
                candidate = child;
            }
            else
            {
                candidate = null;
                break;
            }
        }
 
        if ((candidate == null || (!candidate.HasChildren() && !candidate.HasPatterns())) && files == null)
        {
            return NotFoundDirectoryContents.Singleton;
        }
        else
        {
            // We do this to make sure we account for the patterns on the last segment which are not covered by the loop above
            files = GetFilesForCandidatePatterns(segments, candidate, files);
            if (candidate != null && candidate.HasChildren())
            {
                files ??= new(_nameComparer);
                GetCandidateFilesForNode(candidate, files);
            }
 
            return new StaticWebAssetsDirectoryContents((files as IEnumerable<IFileInfo>) ?? Array.Empty<IFileInfo>());
        }
 
        HashSet<IFileInfo>? GetFilesForCandidatePatterns(string[] segments, StaticWebAssetNode? candidate, HashSet<IFileInfo>? files)
        {
            if (candidate != null && candidate.HasPatterns())
            {
                var depth = candidate.Patterns[0].Depth;
                var candidateDirectoryPath = string.Join('/', segments[depth..]);
                foreach (var pattern in candidate.Patterns)
                {
                    var contentRoot = _fileProviders[pattern.ContentRoot];
                    var matcher = new Matcher(_fsComparison);
                    matcher.AddInclude(pattern.Pattern);
                    foreach (var result in contentRoot.GetDirectoryContents(candidateDirectoryPath))
                    {
                        var fileCandidate = string.IsNullOrEmpty(candidateDirectoryPath) ? result.Name : $"{candidateDirectoryPath}/{result.Name}";
                        if (result.Exists && (result.IsDirectory || matcher.Match(fileCandidate).HasMatches))
                        {
                            files ??= new(_nameComparer);
                            if (!files.Contains(result))
                            {
                                // Multiple patterns might match the same file (even at different locations on disk) at runtime. We don't
                                // try to disambiguate anything here, since there is already a build step for it. We just pick the first
                                // file that matches the pattern. The manifest entries are ordered, so while this choice is random, it is
                                // nonetheless deterministic.
                                files.Add(result);
                            }
                        }
                    }
                }
            }
 
            return files;
        }
 
        void GetCandidateFilesForNode(StaticWebAssetNode candidate, HashSet<IFileInfo> files)
        {
            foreach (var child in candidate.Children!)
            {
                var match = child.Value.Match;
                if (match == null)
                {
                    // This is a folder
                    var file = new StaticWebAssetsDirectoryInfo(child.Key);
                    // Entries from the manifest always win over any content based on patterns,
                    // so remove any potentially existing file or folder in favor of the manifest
                    // entry.
                    files.Remove(file);
                    files.Add(file);
                }
                else
                {
                    // This is a file.
                    files.RemoveWhere(f => string.Equals(match.Path, f.Name, _fsComparison));
                    var file = _fileProviders[match.ContentRoot].GetFileInfo(match.Path);
 
                    files.Add(string.Equals(child.Key, match.Path, _fsComparison) ? file :
                        // This means that this file was mapped, there is a chance that we added it to the list
                        // of files by one of the patterns, so we need to replace it with the mapped file.
                        new StaticWebAssetsFileInfo(child.Key, file));
                }
            }
        }
    }
 
    private static string Normalize(string path)
    {
        return path.Replace('\\', '/');
    }
 
    public IFileInfo GetFileInfo(string subpath)
    {
        ArgumentNullException.ThrowIfNull(subpath);
 
        var segments = subpath.Split('/', StringSplitOptions.RemoveEmptyEntries);
        StaticWebAssetNode? candidate = _root;
        List<StaticWebAssetPattern>? patterns = null;
 
        // Iterate over the path segments until we reach the destination, collecting
        // all pattern candidates along the way except for any pattern at the root.
        for (var i = 0; i < segments.Length; i++)
        {
            if (candidate.HasPatterns())
            {
                patterns ??= new();
                patterns.AddRange(candidate.Patterns);
            }
            if (candidate.HasChildren() && candidate.Children.TryGetValue(segments[i], out var child))
            {
                candidate = child;
            }
            else
            {
                candidate = null;
                break;
            }
        }
 
        var match = candidate?.Match;
        if (match != null)
        {
            // If we found a file, that wins over anything else. If there are conflicts with files added after
            // we've built the manifest, we'll be notified the next time we do a build. This is not different
            // from previous Static Web Assets versions.
            var file = _fileProviders[match.ContentRoot].GetFileInfo(match.Path);
            if (!file.Exists || string.Equals(subpath, Normalize(match.Path), _fsComparison))
            {
                return file;
            }
            else
            {
                return new StaticWebAssetsFileInfo(segments[^1], file);
            }
        }
 
        // The list of patterns is ordered by pattern depth, so we compute the string to check for patterns only
        // once per level. We don't aim to solve conflicts here where multiple files could match a given path,
        // we have a build check that takes care of that.
        var currentDepth = -1;
        var candidatePath = subpath;
 
        if (patterns != null)
        {
            for (var i = 0; i < patterns.Count; i++)
            {
                var pattern = patterns[i];
                if (pattern.Depth != currentDepth)
                {
                    currentDepth = pattern.Depth;
                    candidatePath = string.Join('/', segments[currentDepth..]);
                }
 
                var result = _fileProviders[pattern.ContentRoot].GetFileInfo(candidatePath);
                if (result.Exists)
                {
                    if (!result.IsDirectory)
                    {
                        var matcher = new Matcher();
                        matcher.AddInclude(pattern.Pattern);
                        if (!matcher.Match(candidatePath).HasMatches)
                        {
                            continue;
                        }
 
                        return result;
                    }
                }
            }
        }
 
        return new NotFoundFileInfo(subpath);
    }
 
    public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
 
    private sealed class StaticWebAssetsDirectoryContents : IDirectoryContents
    {
        private readonly IEnumerable<IFileInfo> _files;
 
        public StaticWebAssetsDirectoryContents(IEnumerable<IFileInfo> files) =>
            _files = files;
 
        public bool Exists => true;
 
        public IEnumerator<IFileInfo> GetEnumerator() => _files.GetEnumerator();
 
        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    }
 
    private sealed class StaticWebAssetsDirectoryInfo : IFileInfo
    {
        private static readonly DateTimeOffset _lastModified = DateTimeOffset.FromUnixTimeSeconds(0);
 
        public StaticWebAssetsDirectoryInfo(string name)
        {
            Name = name;
        }
 
        public bool Exists => true;
 
        public long Length => 0;
 
        public string? PhysicalPath => null;
 
        public DateTimeOffset LastModified => _lastModified;
 
        public bool IsDirectory => true;
 
        public string Name { get; }
 
        public Stream CreateReadStream() => throw new InvalidOperationException("Can not create a stream for a directory.");
    }
 
    private sealed class StaticWebAssetsFileInfo : IFileInfo
    {
        private readonly IFileInfo _source;
 
        public StaticWebAssetsFileInfo(string name, IFileInfo source)
        {
            Name = name;
            _source = source;
        }
        public bool Exists => _source.Exists;
 
        public long Length => _source.Length;
 
        public string PhysicalPath => _source.PhysicalPath ?? string.Empty;
 
        public DateTimeOffset LastModified => _source.LastModified;
 
        public bool IsDirectory => _source.IsDirectory;
 
        public string Name { get; }
 
        public Stream CreateReadStream() => _source.CreateReadStream();
    }
 
    private sealed class FileNameComparer : IEqualityComparer<IFileInfo>
    {
        public bool Equals(IFileInfo? x, IFileInfo? y) => string.Equals(x?.Name, y?.Name, _fsComparison);
 
        public int GetHashCode(IFileInfo obj) => obj.Name.GetHashCode(_fsComparison);
    }
 
    internal sealed class StaticWebAssetManifest
    {
        internal static readonly StringComparer PathComparer =
            OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
 
        public string[] ContentRoots { get; set; } = Array.Empty<string>();
 
        public StaticWebAssetNode Root { get; set; } = null!;
 
        internal static StaticWebAssetManifest Parse(Stream manifest)
        {
            return JsonSerializer.Deserialize(
                manifest,
                SourceGenerationContext.DefaultWithConverter.StaticWebAssetManifest)!;
        }
    }
 
    [JsonSourceGenerationOptions]
    [JsonSerializable(typeof(StaticWebAssetManifest))]
    [JsonSerializable(typeof(IDictionary<string, StaticWebAssetNode>))]
    internal sealed partial class SourceGenerationContext : JsonSerializerContext
    {
        public static readonly SourceGenerationContext DefaultWithConverter = new SourceGenerationContext(new JsonSerializerOptions
        {
            Converters = { new OSBasedCaseConverter() }
        });
    }
 
    internal sealed class StaticWebAssetNode
    {
        [JsonPropertyName("Asset")]
        public StaticWebAssetMatch? Match { get; set; }
 
        [JsonConverter(typeof(OSBasedCaseConverter))]
        public Dictionary<string, StaticWebAssetNode>? Children { get; set; }
 
        public StaticWebAssetPattern[]? Patterns { get; set; }
 
        [MemberNotNullWhen(true, nameof(Children))]
        internal bool HasChildren() => Children != null && Children.Count > 0;
 
        [MemberNotNullWhen(true, nameof(Patterns))]
        internal bool HasPatterns() => Patterns != null && Patterns.Length > 0;
    }
 
    internal sealed class StaticWebAssetMatch
    {
        [JsonPropertyName("ContentRootIndex")]
        public int ContentRoot { get; set; }
 
        [JsonPropertyName("SubPath")]
        public string Path { get; set; } = null!;
    }
 
    internal sealed class StaticWebAssetPattern
    {
        [JsonPropertyName("ContentRootIndex")]
        public int ContentRoot { get; set; }
 
        public int Depth { get; set; }
 
        public string Pattern { get; set; } = null!;
    }
 
    private sealed class OSBasedCaseConverter : JsonConverter<Dictionary<string, StaticWebAssetNode>>
    {
        public override Dictionary<string, StaticWebAssetNode> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            // Need to recursively deserialize `Dictionary<string, StaticWebAssetNode>` but can't deserialize
            // that type directly because this converter will call into itself and stackoverflow.
            // Workaround is to deserialize to IDictionary, and then perform custom convert logic on the result.
            var parsed = JsonSerializer.Deserialize(ref reader, SourceGenerationContext.DefaultWithConverter.IDictionaryStringStaticWebAssetNode)!;
            var result = new Dictionary<string, StaticWebAssetNode>(StaticWebAssetManifest.PathComparer);
            MergeChildren(parsed, result);
            return result;
 
            static void MergeChildren(
                IDictionary<string, StaticWebAssetNode> newChildren,
                IDictionary<string, StaticWebAssetNode> existing)
            {
                foreach (var (key, value) in newChildren)
                {
                    if (!existing.TryGetValue(key, out var existingNode))
                    {
                        existing.Add(key, value);
                    }
                    else
                    {
                        if (value.Patterns != null)
                        {
                            if (existingNode.Patterns == null)
                            {
                                existingNode.Patterns = value.Patterns;
                            }
                            else
                            {
                                if (value.Patterns.Length > 0)
                                {
                                    var newList = new StaticWebAssetPattern[existingNode.Patterns.Length + value.Patterns.Length];
                                    existingNode.Patterns.CopyTo(newList, 0);
                                    value.Patterns.CopyTo(newList, existingNode.Patterns.Length);
                                    existingNode.Patterns = newList;
                                }
                            }
                        }
 
                        if (value.Children != null)
                        {
                            if (existingNode.Children == null)
                            {
                                existingNode.Children = value.Children;
                            }
                            else
                            {
                                if (value.Children.Count > 0)
                                {
                                    MergeChildren(value.Children, existingNode.Children);
                                }
                            }
                        }
                    }
                }
            }
        }
 
        public override void Write(Utf8JsonWriter writer, Dictionary<string, StaticWebAssetNode> value, JsonSerializerOptions options)
        {
            throw new NotSupportedException();
        }
    }
}