diff --git a/Directory.Packages.props b/Directory.Packages.props index fe3463c..9e44184 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,8 +10,8 @@ - - + + - \ No newline at end of file + diff --git a/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs b/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs index 6021836..89f8986 100644 --- a/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs +++ b/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs @@ -1,4 +1,4 @@ -using Ramstack.Globbing; +using Ramstack.FileProviders.Internal; namespace Ramstack.FileProviders; @@ -60,10 +60,9 @@ public GlobbingFileProvider(IFileProvider provider, string[] patterns, string[]? public IFileInfo GetFileInfo(string subpath) { subpath = FilePath.Normalize(subpath); - if (!IsExcluded(subpath) && IsIncluded(subpath)) - return _provider.GetFileInfo(subpath); - - return new NotFoundFileInfo(subpath); + return IsFileIncluded(subpath) + ? _provider.GetFileInfo(subpath) + : new NotFoundFileInfo(subpath); } /// @@ -71,7 +70,7 @@ public IDirectoryContents GetDirectoryContents(string subpath) { subpath = FilePath.Normalize(subpath); - if (!IsExcluded(subpath)) + if (IsDirectoryIncluded(subpath)) { var directory = _provider.GetDirectoryContents(subpath); if (directory is not NotFoundDirectoryContents) @@ -85,23 +84,26 @@ public IDirectoryContents GetDirectoryContents(string subpath) public IChangeToken Watch(string filter) => _provider.Watch(filter); - private bool IsIncluded(string path) - { - foreach (var pattern in _patterns) - if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) - return true; - - return false; - } - - private bool IsExcluded(string path) - { - foreach (var pattern in _excludes) - if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) - return true; + /// + /// Determines if a file is included based on the specified patterns and exclusions. + /// + /// The path of the file. + /// + /// if the file is included; + /// otherwise, . + /// + private bool IsFileIncluded(string path) => + !PathHelper.IsMatch(path, _excludes) && PathHelper.IsMatch(path, _patterns); - return false; - } + /// + /// Determines if a directory is included based on the specified exclusions. + /// + /// The path of the directory. + /// + /// if the directory is included; otherwise, . + /// + private bool IsDirectoryIncluded(string path) => + path == "/" || !PathHelper.IsMatch(path, _excludes) && PathHelper.IsPartialMatch(path, _patterns); #region Inner type: GlobbingDirectoryContents @@ -136,9 +138,8 @@ public IEnumerator GetEnumerator() foreach (var file in _directory) { var path = FilePath.Join(_directoryPath, file.Name); - if (!_provider.IsExcluded(path)) - if (file.IsDirectory || _provider.IsIncluded(path)) - yield return file; + if (file.IsDirectory ? _provider.IsDirectoryIncluded(path) : _provider.IsFileIncluded(path)) + yield return file; } } diff --git a/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs b/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs new file mode 100644 index 0000000..ea0a405 --- /dev/null +++ b/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs @@ -0,0 +1,339 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.Arm; +using System.Runtime.Intrinsics.X86; + +using Ramstack.Globbing; + +namespace Ramstack.FileProviders.Internal; + +/// +/// Provides helper methods for path manipulations. +/// +internal static class PathHelper +{ + /// + /// Determines whether the specified path matches any of the specified patterns. + /// + /// The path to match for a match. + /// An array of patterns to match against the path. + /// + /// if the path matches any of the patterns; + /// otherwise, . + /// + public static bool IsMatch(scoped ReadOnlySpan path, string[] patterns) + { + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Determines whether the specified path partially matches any of the specified patterns. + /// + /// The path to be partially matched. + /// An array of patterns to match against the path. + /// + /// if the path partially matches any of the patterns; + /// otherwise, . + /// + public static bool IsPartialMatch(scoped ReadOnlySpan path, string[] patterns) + { + Debug.Assert(path is not "/"); + + var count = CountPathSegments(path); + + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, GetPartialPattern(pattern, count), MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Counts the number of segments in the specified path. + /// + /// The path to count segments for. + /// + /// The number of segments in the path. + /// + private static int CountPathSegments(scoped ReadOnlySpan path) + { + var count = 0; + var iterator = new PathSegmentIterator(); + ref var s = ref Unsafe.AsRef(in MemoryMarshal.GetReference(path)); + var length = path.Length; + + while (true) + { + var r = iterator.GetNext(ref s, length); + + if (r.start != r.final) + count++; + + if (r.final == length) + break; + } + + if (count == 0) + count = 1; + + return count; + } + + /// + /// Returns a partial pattern from the specified pattern string based on the specified depth. + /// + /// The pattern string to extract from. + /// The depth level to extract the partial pattern up to. + /// + /// A representing the partial pattern. + /// + private static ReadOnlySpan GetPartialPattern(string pattern, int depth) + { + Debug.Assert(depth >= 1); + + var iterator = new PathSegmentIterator(); + ref var s = ref Unsafe.AsRef(in pattern.GetPinnableReference()); + var length = pattern.Length; + + while (true) + { + var r = iterator.GetNext(ref s, length); + if (r.start != r.final) + depth--; + + if (depth < 1 + || r.final == length + || IsGlobStar(ref s, r.start, r.final)) + return MemoryMarshal.CreateReadOnlySpan(ref s, r.final); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IsGlobStar(ref char s, int index, int final) => + index + 2 == final && Unsafe.ReadUnaligned( + ref Unsafe.As( + ref Unsafe.Add(ref s, (nint)(uint)index))) == ('*' << 16 | '*'); + } + + #region Vector helper methods + + /// + /// Loads a 256-bit vector from the specified source. + /// + /// The source from which the vector will be loaded. + /// The offset from the from which the vector will be loaded. + /// + /// The loaded 256-bit vector. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector256 LoadVector256(ref char source, nint offset) => + Unsafe.ReadUnaligned>( + ref Unsafe.As(ref Unsafe.Add(ref source, offset))); + + /// + /// Loads a 128-bit vector from the specified source. + /// + /// The source from which the vector will be loaded. + /// The offset from from which the vector will be loaded. + /// + /// The loaded 128-bit vector. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector128 LoadVector128(ref char source, nint offset) => + Unsafe.ReadUnaligned>( + ref Unsafe.As( + ref Unsafe.Add(ref source, offset))); + + #endregion + + #region Inner type: PathSegmentIterator + + /// + /// Provides functionality to iterate over segments of a path. + /// + private struct PathSegmentIterator + { + private int _last; + private nint _position; + private uint _mask; + + /// + /// Initializes a new instance of the structure. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PathSegmentIterator() => + _last = -1; + + /// + /// Retrieves the next segment of the path. + /// + /// A reference to the starting character of the path. + /// The total number of characters in the input path starting from . + /// + /// A tuple containing the start and end indices of the next path segment. + /// start indicates the beginning of the segment, and final satisfies + /// the condition that final - start equals the length of the segment. + /// The end of the iteration is indicated by final being equal to the length of the path. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public (int start, int final) GetNext(ref char source, int length) + { + var start = _last + 1; + + while ((int)_position < length) + { + if ((Avx2.IsSupported || Sse2.IsSupported || AdvSimd.Arm64.IsSupported) && _mask != 0) + { + var offset = BitOperations.TrailingZeroCount(_mask); + if (AdvSimd.IsSupported) + { + // + // On ARM, ExtractMostSignificantBits returns a mask where each bit + // represents one vector element (1 bit per ushort), so offset + // directly corresponds to the element index + // + _last = (int)(_position + (nint)(uint)offset); + + // + // Clear the bits for the current separator + // + _mask &= ~(1u << offset); + } + else + { + // + // On x86, MoveMask (and ExtractMostSignificantBits on byte-based vectors) + // returns a mask where each bit represents one byte (2 bits per ushort), + // so we need to divide offset by 2 to get the actual element index + // + _last = (int)(_position + (nint)((uint)offset >> 1)); + + // + // Clear the bits for the current separator + // + _mask &= ~(0b_11u << offset); + } + + // + // Advance position to the next chunk when no separators remain in the mask + // + if (_mask == 0) + { + // + // https://github.com/dotnet/runtime/issues/117416 + // + // Precompute the stride size instead of calculating it inline + // to avoid stack spilling. For some unknown reason, the JIT + // fails to optimize properly when this is written inline, like so: + // _position += Avx2.IsSupported + // ? Vector256.Count + // : Vector128.Count; + // + + var stride = Avx2.IsSupported + ? Vector256.Count + : Vector128.Count; + + _position += stride; + } + + return (start, _last); + } + + if (Avx2.IsSupported && (int)_position + Vector256.Count <= length) + { + var chunk = LoadVector256(ref source, _position); + var slash = Vector256.Create('/'); + var comparison = Avx2.CompareEqual(chunk, slash); + + // + // Store the comparison bitmask and reuse it across iterations + // as long as it contains non-zero bits. + // This avoids reloading SIMD registers and repeating comparisons + // on the same chunk of data. + // + _mask = (uint)Avx2.MoveMask(comparison.AsByte()); + + // + // Advance position to the next chunk when no separators found + // + if (_mask == 0) + _position += Vector256.Count; + } + else if (Sse2.IsSupported && !Avx2.IsSupported && (int)_position + Vector128.Count <= length) + { + var chunk = LoadVector128(ref source, _position); + var slash = Vector128.Create('/'); + var comparison = Sse2.CompareEqual(chunk, slash); + + // + // Store the comparison bitmask and reuse it across iterations + // as long as it contains non-zero bits. + // This avoids reloading SIMD registers and repeating comparisons + // on the same chunk of data. + // + _mask = (uint)Sse2.MoveMask(comparison.AsByte()); + + // + // Advance position to the next chunk when no separators found + // + if (_mask == 0) + _position += Vector128.Count; + } + else if (AdvSimd.Arm64.IsSupported && (int)_position + Vector128.Count <= length) + { + var chunk = LoadVector128(ref source, _position); + var slash = Vector128.Create('/'); + var comparison = AdvSimd.CompareEqual(chunk, slash); + + // + // Store the comparison bitmask and reuse it across iterations + // as long as it contains non-zero bits. + // This avoids reloading SIMD registers and repeating comparisons + // on the same chunk of data. + // + _mask = ExtractMostSignificantBits(comparison); + + // + // Advance position to the next chunk when no separators found + // + if (_mask == 0) + _position += Vector128.Count; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static uint ExtractMostSignificantBits(Vector128 v) + { + var sum = AdvSimd.Arm64.AddAcross( + AdvSimd.ShiftLogical( + AdvSimd.And(v, Vector128.Create((ushort)0x8000)), + Vector128.Create(-15, -14, -13, -12, -11, -10, -9, -8))); + return sum.ToScalar(); + } + } + else + { + for (; (int)_position < length; _position++) + { + var ch = Unsafe.Add(ref source, _position); + if (ch == '/') + { + _last = (int)_position; + _position++; + + return (start, _last); + } + } + } + } + + return (start, length); + } + } + + #endregion +} diff --git a/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs index 68a80a4..d74c2ef 100644 --- a/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs @@ -61,12 +61,9 @@ public void GetFileInfo_NonExistingFile() { using var provider = CreateFileProvider(); - var name = $"{Guid.NewGuid()}.txt"; - var info = provider.GetFileInfo(name); + var info = provider.GetFileInfo("/project/8f723faf0ee0.txt"); - Assert.That( - FilePath.GetFileName(info.Name), - Is.EqualTo(name)); + Assert.That(info.Name, Is.EqualTo("8f723faf0ee0.txt").Or.EqualTo("/project/8f723faf0ee0.txt")); Assert.That(info.IsDirectory, Is.False); Assert.That(info.Exists, Is.False); } @@ -76,8 +73,7 @@ public void GetDirectoryContents_NonExistingDirectory() { using var provider = CreateFileProvider(); - var name = Guid.NewGuid().ToString(); - var info = provider.GetDirectoryContents($"/{name}"); + var info = provider.GetDirectoryContents("/project/c800f57d/7c71"); Assert.That(info.Exists, Is.False); } diff --git a/tests/Ramstack.FileProviders.Tests/FilePathTests.cs b/tests/Ramstack.FileProviders.Tests/FilePathTests.cs index fd274f3..25febd8 100644 --- a/tests/Ramstack.FileProviders.Tests/FilePathTests.cs +++ b/tests/Ramstack.FileProviders.Tests/FilePathTests.cs @@ -45,6 +45,7 @@ public void GetFileName(string path, string expected) [TestCase("", "")] [TestCase("/", "")] + [TestCase("dir", "")] [TestCase("/dir", "/")] [TestCase("/dir/file", "/dir")] [TestCase("/dir/dir/", "/dir/dir")] diff --git a/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs index 1c6f7fd..b605cde 100644 --- a/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs @@ -5,33 +5,85 @@ namespace Ramstack.FileProviders; [TestFixture] public class GlobbingFileProviderTests : AbstractFileProviderTests { - private readonly TempFileStorage _storage = new TempFileStorage(); + private readonly TempFileStorage _storage1; + private readonly TempFileStorage _storage2; + + public GlobbingFileProviderTests() + { + _storage1 = new TempFileStorage(); + _storage2 = new TempFileStorage(_storage1); + } [OneTimeSetUp] public void Setup() { - var path = Path.Join(_storage.Root, "project"); - var directory = new DirectoryInfo(path); + var directory = new DirectoryInfo( + Path.Join(_storage1.Root, "project")); foreach (var di in directory.GetDirectories("*", SearchOption.TopDirectoryOnly)) - if (di.Name != "docs") + if (di.Name != "assets") di.Delete(recursive: true); foreach (var fi in directory.GetFiles("*", SearchOption.TopDirectoryOnly)) - fi.Delete(); - - File.Delete(Path.Join(_storage.Root, "project/docs/troubleshooting/common_issues.txt")); + if (fi.Name != "README.md") + fi.Delete(); } [OneTimeTearDown] - public void Cleanup() => - _storage.Dispose(); + public void Cleanup() + { + _storage1.Dispose(); + _storage2.Dispose(); + } + + [Test] + public void Glob_MatchStructures() + { + using var provider = CreateFileProvider(); + + Assert.That( + provider + .EnumerateDirectories("/", "**") + .OrderBy(f => f.FullName) + .Select(f => f.FullName) + .ToArray(), + Is.EquivalentTo( + [ + "/project", + "/project/assets", + "/project/assets/fonts", + "/project/assets/images", + "/project/assets/images/backgrounds", + "/project/assets/styles" + ])); + + Assert.That( + provider + .EnumerateFiles("/", "**") + .OrderBy(f => f.FullName) + .Select(f => f.FullName) + .ToArray(), + Is.EquivalentTo( + [ + "/project/assets/fonts/Arial.ttf", + "/project/assets/fonts/Roboto.ttf", + "/project/assets/images/backgrounds/dark.jpeg", + "/project/assets/images/backgrounds/light.jpg", + "/project/assets/images/icon.svg", + "/project/assets/images/logo.png", + "/project/assets/styles/main.css", + "/project/assets/styles/print.css", + "/project/README.md" + ])); + } [Test] public void ExcludedDirectory_HasNoFileNodes() { - using var storage = new TempFileStorage(); - var fs = new GlobbingFileProvider(new PhysicalFileProvider(storage.Root), "**", exclude: "/project/src/**"); + var provider = new GlobbingFileProvider( + new PhysicalFileProvider(_storage2.Root), + pattern: "**", + exclude: "/project/src/**"); var directories = new[] { @@ -45,15 +97,15 @@ public void ExcludedDirectory_HasNoFileNodes() foreach (var path in directories) { - var directory = fs.GetDirectory(path); + var directory = provider.GetDirectoryContents(path); Assert.That( directory.Exists, Is.False); Assert.That( - directory.EnumerateFileNodes("**").Count(), - Is.Zero); + directory.Any(), + Is.False); } var files = new[] @@ -73,17 +125,19 @@ public void ExcludedDirectory_HasNoFileNodes() foreach (var path in files) { - var file = fs.GetFile(path); + var file = provider.GetFileInfo(path); Assert.That(file.Exists, Is.False); } } protected override IFileProvider GetFileProvider() { - var provider = new PhysicalFileProvider(_storage.Root); - return new GlobbingFileProvider(provider, "project/docs/**", exclude: "**/*.txt"); + var provider = new PhysicalFileProvider(_storage2.Root); + return new GlobbingFileProvider(provider, + patterns: ["project/assets/**", "project/README.md", "project/global.json"], + excludes: ["project/*.json"]); } protected override DirectoryInfo GetDirectoryInfo() => - new DirectoryInfo(_storage.Root); + new DirectoryInfo(_storage1.Root); } diff --git a/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs index 6e9f741..f18f2f8 100644 --- a/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs @@ -12,12 +12,12 @@ public sealed class PrefixedFileProviderTests : AbstractFileProviderTests .GetMethod("ResolveGlobFilter", BindingFlags.Static | BindingFlags.NonPublic)! .CreateDelegate>(); - private const string Prefix = "solution/app"; - - private readonly TempFileStorage _storage = new TempFileStorage(Prefix); + private readonly TempFileStorage _storage = new TempFileStorage(); protected override IFileProvider GetFileProvider() => - new PrefixedFileProvider(Prefix, new PhysicalFileProvider(_storage.PrefixedPath, ExclusionFilters.None)); + new PrefixedFileProvider("/project", + new PhysicalFileProvider( + Path.Join(_storage.Root, "project"))); protected override DirectoryInfo GetDirectoryInfo() => new DirectoryInfo(_storage.Root); diff --git a/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs index a551628..270f0a0 100644 --- a/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs @@ -12,8 +12,10 @@ public void Cleanup() => _storage.Dispose(); protected override IFileProvider GetFileProvider() => - new SubFileProvider("/project/docs", new PhysicalFileProvider(_storage.Root)); + new SubFileProvider("/bin/app", + new PrefixedFileProvider("/bin/app", + new PhysicalFileProvider(_storage.Root))); protected override DirectoryInfo GetDirectoryInfo() => - new DirectoryInfo(Path.Join(_storage.Root, "project", "docs")); + new DirectoryInfo(Path.Join(_storage.Root)); } diff --git a/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs b/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs index ddf3001..574736c 100644 --- a/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs +++ b/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs @@ -2,19 +2,11 @@ namespace Ramstack.FileProviders.Utilities; public sealed class TempFileStorage : IDisposable { - public string Root { get; } - public string PrefixedPath { get; } + public string Root { get; } = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - public TempFileStorage(string prefix = "") + public TempFileStorage() { - var root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - var path = prefix.Length != 0 - ? Path.GetFullPath(Path.Join(root, prefix)) - : root; - - Root = root; - PrefixedPath = path; - + var path = Root; var list = new[] { "project/docs/user_manual.pdf", @@ -53,10 +45,6 @@ public TempFileStorage(string prefix = "") "project/data/temp/temp_file1.tmp", "project/data/temp/temp_file2.tmp", "project/data/temp/ac/b2/34/2d/7e/temp_file2.tmp", - "project/data/temp/hidden-folder/temp_1.tmp", - "project/data/temp/hidden-folder/temp_2.tmp", - "project/data/temp/hidden/temp_hidden3.dat", - "project/data/temp/hidden/temp_hidden4.dat", "project/scripts/setup.p1", "project/scripts/deploy.ps1", @@ -78,13 +66,13 @@ public TempFileStorage(string prefix = "") "project/assets/images/backgrounds/light.jpg", "project/assets/images/backgrounds/dark.jpeg", - "project/assets/fonts/opensans.ttf", - "project/assets/fonts/roboto.ttf", + "project/assets/fonts/Arial.ttf", + "project/assets/fonts/Roboto.ttf", "project/assets/styles/main.css", "project/assets/styles/print.css", "project/packages/Ramstack.Globbing.2.1.0/lib/net60/Ramstack.Globbing.dll", - "project/packages/Ramstack.Globbing.2.1.0/Ramstack.Globbing.2.1.0.nupkg", + "project/packages/Ramstack.Globbing.2.1.0/Ramstack.Globbing.2.1.0.zip", "project/.gitignore", "project/.editorconfig", @@ -106,12 +94,29 @@ public TempFileStorage(string prefix = "") foreach (var f in list) File.WriteAllText(f, $"Automatically generated on {DateTime.Now:s}\n\nId:{Guid.NewGuid()}"); + } - var hiddenFolder = directories.First(p => p.Contains("hidden-folder")); - File.SetAttributes(hiddenFolder, FileAttributes.Hidden); + public TempFileStorage(TempFileStorage storage) + { + CopyDirectory(storage.Root, Root); - foreach (var hiddenFile in list.Where(p => p.Contains("temp_hidden"))) - File.SetAttributes(hiddenFile, FileAttributes.Hidden); + static void CopyDirectory(string sourcePath, string destinationPath) + { + Directory.CreateDirectory(destinationPath); + + foreach (var sourceFileName in Directory.GetFiles(sourcePath)) + { + var name = Path.GetFileName(sourceFileName); + var destFileName = Path.Join(destinationPath, name); + File.Copy(sourceFileName, destFileName); + } + + foreach (var directoryPath in Directory.GetDirectories(sourcePath)) + { + var destination = Path.Join(destinationPath, Path.GetFileName(directoryPath)); + CopyDirectory(directoryPath, destination); + } + } } public void Dispose() => diff --git a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs index 9fed1d7..eee0c4e 100644 --- a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs @@ -8,7 +8,11 @@ namespace Ramstack.FileProviders; public class ZipFileProviderTests : AbstractFileProviderTests { private readonly TempFileStorage _storage = new TempFileStorage(); - private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + private readonly string _path = + Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ) + ".zip"; [OneTimeSetUp] public void Setup()