From ec2aa2470ff01ea2b15065349987662261a3132f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 5 Jul 2026 19:35:36 +1000 Subject: [PATCH 1/2] Fix VerifySettings copy constructor, temp cleanup, and stream/extension edge cases - VerifySettings copy ctor: copy throwException (was dropped, so settings.AutoVerify(throwException: true) passed as an instance silently did not throw on mismatch) - VerifySettings copy ctor: clone the mutable collections it was aliasing (appendedFiles, the extension stream/string comparer dictionaries, and the inner lists of ExtensionMappedInstanceScrubbers), so per-test fluent config no longer leaks into a shared base settings instance - TempDirectory/TempFile cleanup: catch IOException/UnauthorizedAccessException for aged-out orphans instead of DirectoryNotFoundException/FileNotFoundException (which File.Delete never throws), so a locked or read-only orphan no longer fails the module initializer on every run - IoHelpers.WriteStream: fall back to copying through the handle when File.Copy cannot re-open the source by path (writable / FileShare.None, or a handle-based stream), resetting position only in that fallback - Extensions.Extension(FileStream): return "noextension" for extension-less files instead of throwing ArgumentOutOfRangeException - TempDirectory/TempFile: guard the AsyncLocal path list with a lock on add/remove/scrub iteration - Add tests for the above --- src/Verify.Tests/CopyConstructorTests.cs | 58 ++++++++++++++++++++++++ src/Verify.Tests/IoHelpersTests.cs | 40 ++++++++++++++++ src/Verify.Tests/TempDirectoryTests.cs | 22 +++++++++ src/Verify.Tests/TempFileTests.cs | 49 ++++++++++++++++++++ src/Verify/Extensions.cs | 13 +++++- src/Verify/IoHelpers.cs | 39 +++++++++++++++- src/Verify/TempDirectory.cs | 37 ++++++++++----- src/Verify/TempFile.cs | 37 ++++++++++----- src/Verify/VerifySettings.cs | 14 ++++-- 9 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 src/Verify.Tests/CopyConstructorTests.cs diff --git a/src/Verify.Tests/CopyConstructorTests.cs b/src/Verify.Tests/CopyConstructorTests.cs new file mode 100644 index 0000000000..81b6324230 --- /dev/null +++ b/src/Verify.Tests/CopyConstructorTests.cs @@ -0,0 +1,58 @@ +public class CopyConstructorTests +{ + [Fact] + public void PreservesThrowException() + { + var settings = new VerifySettings(); + settings.AutoVerify(throwException: true); + + var copy = new VerifySettings(settings); + + Assert.True(copy.throwException); + } + + [Fact] + public void ClonesAppendedFiles() + { + var settings = new VerifySettings(); + settings.AppendContentAsFile("base"); + + var copy = new VerifySettings(settings); + copy.AppendContentAsFile("extra"); + + // The base settings must not see the file appended to the copy. + Assert.Single(settings.appendedFiles!); + Assert.Equal(2, copy.appendedFiles!.Count); + } + + [Fact] + public void ClonesExtensionStreamComparers() + { + var settings = new VerifySettings(); + StreamCompare compare = (_, _, _) => Task.FromResult(CompareResult.Equal); + // Extensions unique to this test so the global comparer registry can't + // satisfy the lookup below. + settings.UseStreamComparer(compare, "copyctorext1"); + + var copy = new VerifySettings(settings); + copy.UseStreamComparer(compare, "copyctorext2"); + + // The comparer added to the copy must not leak into the base settings. + Assert.False(settings.TryFindStreamComparer("copyctorext2", out _)); + Assert.True(copy.TryFindStreamComparer("copyctorext2", out _)); + } + + [Fact] + public void ClonesExtensionMappedScrubbers() + { + var settings = new VerifySettings(); + settings.AddScrubber("txt", _ => { }); + + var copy = new VerifySettings(settings); + copy.AddScrubber("txt", _ => { }); + + // The inner list is shared unless deep-copied. + Assert.Single(settings.ExtensionMappedInstanceScrubbers!["txt"]); + Assert.Equal(2, copy.ExtensionMappedInstanceScrubbers!["txt"].Count); + } +} diff --git a/src/Verify.Tests/IoHelpersTests.cs b/src/Verify.Tests/IoHelpersTests.cs index 779af602c8..25c029dd13 100644 --- a/src/Verify.Tests/IoHelpersTests.cs +++ b/src/Verify.Tests/IoHelpersTests.cs @@ -18,4 +18,44 @@ public void ResolveDirectoryNameFromSourceFileTests(string sourceFile, string ex Assert.Equal(expectedDirectory, IoHelpers.ResolveDirectoryFromSourceFile(sourceFile)); } + + [Fact] + public async Task WriteStreamHandlesExclusiveFileStream() + { + using var source = new TempFile(".bin"); + await File.WriteAllBytesAsync(source, [1, 2, 3, 4, 5, 6, 7, 8]); + var destination = Path.Combine(Path.GetTempPath(), $"WriteStream{Guid.NewGuid():N}.bin"); + try + { + // Writable + FileShare.None means File.Copy cannot re-open the source + // by path on Windows; WriteStream must fall back to the handle. + using var stream = new FileStream(source, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + await IoHelpers.WriteStream(destination, stream); + + Assert.True(File.Exists(destination)); + Assert.Equal(8, new FileInfo(destination).Length); + } + finally + { + File.Delete(destination); + } + } + + [Fact] + public void ExtensionForExtensionlessFileStream() + { + var directory = Path.Combine(Path.GetTempPath(), $"ExtTest{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "Dockerfile"); + File.WriteAllText(path, "content"); + try + { + using var stream = File.OpenRead(path); + Assert.Equal("noextension", stream.Extension()); + } + finally + { + Directory.Delete(directory, true); + } + } } \ No newline at end of file diff --git a/src/Verify.Tests/TempDirectoryTests.cs b/src/Verify.Tests/TempDirectoryTests.cs index b88ae3f62f..36284a1948 100644 --- a/src/Verify.Tests/TempDirectoryTests.cs +++ b/src/Verify.Tests/TempDirectoryTests.cs @@ -427,6 +427,28 @@ public void IgnoreLockedFiles_DefaultIsFalse() } } + [Fact] + public void CleanupDoesNotThrowForUndeletableDirectory() + { + var directoryPath = Path.Combine(TempDirectory.RootDirectory, Path.GetRandomFileName()); + Directory.CreateDirectory(directoryPath); + var filePath = Path.Combine(directoryPath, "locked.txt"); + File.WriteAllText(filePath, "content"); + PreventDeletion(filePath, directoryPath); + Directory.SetLastWriteTime(directoryPath, DateTime.Now.AddDays(-2)); + try + { + // An aged-out orphan that cannot be deleted must not fail the whole + // module-initializer cleanup. + TempDirectory.Cleanup(); + } + finally + { + AllowDeletion(filePath, directoryPath); + Directory.Delete(directoryPath, true); + } + } + // On Windows, ReadOnly on a file prevents deletion. // On Unix, file deletion is controlled by the parent directory's write permission. static void PreventDeletion(string filePath, string directoryPath) diff --git a/src/Verify.Tests/TempFileTests.cs b/src/Verify.Tests/TempFileTests.cs index 0003f07ecf..5a74c319f4 100644 --- a/src/Verify.Tests/TempFileTests.cs +++ b/src/Verify.Tests/TempFileTests.cs @@ -23,6 +23,55 @@ public void Constructor_WithExtension_AppliesExtension() Assert.EndsWith(".txt", temp.Path); } + [Fact] + public void CleanupDoesNotThrowForUndeletableFile() + { + var filePath = Path.Combine(TempFile.RootDirectory, $"undeletable{Guid.NewGuid():N}.txt"); + File.WriteAllText(filePath, "content"); + // Age the file out before marking it read-only: on .NET Framework the + // timestamp cannot be set on a read-only file. + File.SetLastWriteTime(filePath, DateTime.Now.AddDays(-2)); + // On Windows a read-only file cannot be deleted; on Unix deletion is + // governed by the directory, so the file is deletable there and this + // test is a no-op guard. + var readOnly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (readOnly) + { + File.SetAttributes(filePath, FileAttributes.ReadOnly); + } + + try + { + TempFile.Cleanup(); + } + finally + { + if (readOnly) + { + File.SetAttributes(filePath, FileAttributes.Normal); + } + + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Fact] + public async Task ConcurrentInstancesDoNotCorruptState() + { + // Establish the shared async-local path list, then hammer it from many + // child contexts that flow the same list in. + using var parent = new TempFile(); + var tasks = Enumerable.Range(0, 200) + .Select(_ => Task.Run(() => + { + using var temp = new TempFile(); + })); + await Task.WhenAll(tasks); + } + #region VerifyTempFile [Fact] diff --git a/src/Verify/Extensions.cs b/src/Verify/Extensions.cs index deba369601..0873a696e9 100644 --- a/src/Verify/Extensions.cs +++ b/src/Verify/Extensions.cs @@ -20,8 +20,17 @@ static class Extensions public static bool IsNumeric(this Type type) => numericTypes.Contains(Nullable.GetUnderlyingType(type) ?? type); - public static string Extension(this FileStream file) => - Path.GetExtension(file.Name)[1..]; + public static string Extension(this FileStream file) + { + var extension = Path.GetExtension(file.Name); + if (extension.Length == 0) + { + // Matches the convention used for extension-less directory entries. + return "noextension"; + } + + return extension[1..]; + } public static Version MajorMinor(this Version version) => new(version.Major, version.Minor); diff --git a/src/Verify/IoHelpers.cs b/src/Verify/IoHelpers.cs index 5fe104ee3b..39b07c413a 100644 --- a/src/Verify/IoHelpers.cs +++ b/src/Verify/IoHelpers.cs @@ -218,8 +218,18 @@ public static async Task WriteStream(string path, Stream stream) throw new($"Empty data is not allowed. Path: {fileStream.Name}"); } - File.Copy(fileStream.Name, path, true); - return; + if (TryFileCopy(fileStream, path)) + { + return; + } + + // The fast path could not re-open the source by path; fall back to + // copying through the existing handle. Reset first so the whole file + // is written, matching File.Copy semantics. + if (fileStream.CanSeek) + { + fileStream.MoveToStart(); + } } // keep using scope to stream is flushed @@ -233,4 +243,29 @@ public static async Task WriteStream(string path, Stream stream) throw new($"Empty data is not allowed. Path: {path}"); } } + + // Copies a FileStream by path (avoiding a read through the managed stream) + // when safe. Returns false — so the caller falls back to a handle copy — for + // handle-based streams (Name is not a rooted path) or when the source cannot + // be re-opened because it is held with a conflicting mode (writable / + // FileShare.None on Windows). + static bool TryFileCopy(FileStream fileStream, string path) + { + var name = fileStream.Name; + if (!Path.IsPathRooted(name)) + { + return false; + } + + try + { + File.Copy(name, path, true); + return true; + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } } \ No newline at end of file diff --git a/src/Verify/TempDirectory.cs b/src/Verify/TempDirectory.cs index 1941f90b93..4b56fa14aa 100644 --- a/src/Verify/TempDirectory.cs +++ b/src/Verify/TempDirectory.cs @@ -71,9 +71,12 @@ public static void Init() return; } - foreach (var path in pathsValue) + lock (pathsLock) { - scrubber.Replace(path, "{TempDirectory}"); + foreach (var path in pathsValue) + { + scrubber.Replace(path, "{TempDirectory}"); + } } }); @@ -81,6 +84,7 @@ public static void Init() } static AsyncLocal?> asyncPaths = new(); + static readonly object pathsLock = new(); internal static void Cleanup() { @@ -98,9 +102,12 @@ internal static void Cleanup() { Directory.Delete(dir, recursive: true); } - catch (DirectoryNotFoundException) + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) { - //Ignore directory cleanup race condition + // Ignore orphans that can't be deleted (locked, read-only, or a + // cleanup race); they are retried on a later run once released. + // DirectoryNotFoundException derives from IOException. } } } @@ -144,14 +151,17 @@ public TempDirectory(bool ignoreLockedFiles) Path = IoPath.Combine(RootDirectory, IoPath.GetRandomFileName()); Directory.CreateDirectory(Path); - paths = asyncPaths.Value!; - if (paths == null) - { - paths = asyncPaths.Value = [Path]; - } - else + lock (pathsLock) { - paths.Add(Path); + paths = asyncPaths.Value!; + if (paths == null) + { + paths = asyncPaths.Value = [Path]; + } + else + { + paths.Add(Path); + } } } @@ -182,7 +192,10 @@ public void Dispose() } } - paths.Remove(Path); + lock (pathsLock) + { + paths.Remove(Path); + } } /// diff --git a/src/Verify/TempFile.cs b/src/Verify/TempFile.cs index c6dc7305e6..612745f375 100644 --- a/src/Verify/TempFile.cs +++ b/src/Verify/TempFile.cs @@ -73,9 +73,12 @@ public static void Init() return; } - foreach (var path in pathsValue) + lock (pathsLock) { - scrubber.Replace(path, "{TempFile}"); + foreach (var path in pathsValue) + { + scrubber.Replace(path, "{TempFile}"); + } } }); @@ -83,6 +86,7 @@ public static void Init() } static AsyncLocal?> asyncPaths = new(); + static readonly object pathsLock = new(); internal static void Cleanup() { @@ -101,9 +105,12 @@ internal static void Cleanup() { File.Delete(file); } - catch (FileNotFoundException) + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) { - // Ignore file cleanup race condition + // Ignore orphans that can't be deleted (locked, read-only, or a + // cleanup race). File.Delete is a no-op for a missing file, so + // FileNotFoundException is not the case that needs handling here. } } } @@ -135,14 +142,17 @@ public TempFile(string? extension = null) Path = IoPath.Combine(RootDirectory, fileName); - paths = asyncPaths.Value!; - if (paths == null) + lock (pathsLock) { - paths = asyncPaths.Value = [Path]; - } - else - { - paths.Add(Path); + paths = asyncPaths.Value!; + if (paths == null) + { + paths = asyncPaths.Value = [Path]; + } + else + { + paths.Add(Path); + } } } @@ -324,7 +334,10 @@ public void Dispose() File.Delete(Path); } - paths.Remove(Path); + lock (pathsLock) + { + paths.Remove(Path); + } } /// diff --git a/src/Verify/VerifySettings.cs b/src/Verify/VerifySettings.cs index 7af23e4415..0066671994 100644 --- a/src/Verify/VerifySettings.cs +++ b/src/Verify/VerifySettings.cs @@ -31,22 +31,28 @@ public VerifySettings(VerifySettings? settings) if (settings.ExtensionMappedInstanceScrubbers != null) { - ExtensionMappedInstanceScrubbers = new(settings.ExtensionMappedInstanceScrubbers); + // Deep copy: the inner lists are mutated in place by AddScrubber. + ExtensionMappedInstanceScrubbers = settings.ExtensionMappedInstanceScrubbers + .ToDictionary(_ => _.Key, _ => _.Value.ToList()); } diffEnabled = settings.diffEnabled; MethodName = settings.MethodName; TypeName = settings.TypeName; strictJson = settings.strictJson; - appendedFiles = settings.appendedFiles; + // Clone the mutable collections below, otherwise per-test fluent config + // (AppendFile/UseStreamComparer/etc.) would mutate a shared base settings + // instance and leak across tests. + appendedFiles = settings.appendedFiles?.ToList(); UniqueDirectory = settings.UniqueDirectory; Directory = settings.Directory; autoVerify = settings.autoVerify; + throwException = settings.throwException; serialization = settings.serialization; stringComparer = settings.stringComparer; streamComparer = settings.streamComparer; - extensionStringComparers = settings.extensionStringComparers; - extensionStreamComparers = settings.extensionStreamComparers; + extensionStringComparers = settings.extensionStringComparers == null ? null : new(settings.extensionStringComparers); + extensionStreamComparers = settings.extensionStreamComparers == null ? null : new(settings.extensionStreamComparers); parameters = settings.parameters; ignoredParameters = settings.ignoredParameters; classArgumentCount = settings.classArgumentCount; From b67fc7d6141795e4f0c576c4bcf6c3abc658e378 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 5 Jul 2026 09:36:39 +0000 Subject: [PATCH 2/2] Docs changes --- docs/naming.md | 2 +- docs/temp-file.md | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/naming.md b/docs/naming.md index 08bc9c461d..125f7333d6 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -789,7 +789,7 @@ public static string NameWithParent(this Type type) return type.Name; } ``` -snippet source | anchor +snippet source | anchor Any path calculated in `DerivePathInfo` should be fully qualified to remove the inconsistency of the current directory. diff --git a/docs/temp-file.md b/docs/temp-file.md index a5bad76486..0bc13ecf8e 100644 --- a/docs/temp-file.md +++ b/docs/temp-file.md @@ -34,7 +34,7 @@ public void Usage() // file automatically deleted here } ``` -snippet source | anchor +snippet source | anchor @@ -62,7 +62,7 @@ public void PathProperty() Assert.True(Path.IsPathRooted(path)); } ``` -snippet source | anchor +snippet source | anchor @@ -91,7 +91,7 @@ public void StringConversion() Trace.WriteLine(content); } ``` -snippet source | anchor +snippet source | anchor @@ -114,7 +114,7 @@ public void FileInfoConversion() Trace.WriteLine(directoryName); } ``` -snippet source | anchor +snippet source | anchor @@ -135,7 +135,7 @@ public void InfoProperty() Trace.WriteLine(directoryName); } ``` -snippet source | anchor +snippet source | anchor @@ -151,7 +151,7 @@ public void RootDirectory() => // Accessing the root directory for all TempDirectory instances Trace.WriteLine(TempFile.RootDirectory); ``` -snippet source | anchor +snippet source | anchor @@ -186,7 +186,7 @@ public async Task VerifyFileInstance() await VerifyFile(file); } ``` -snippet source | anchor +snippet source | anchor @@ -207,7 +207,7 @@ public async Task Scrubbing() }); } ``` -snippet source | anchor +snippet source | anchor Result: @@ -237,7 +237,7 @@ File.WriteAllText(temp, "content"); // file automatically deleted here ``` -snippet source | anchor +snippet source | anchor @@ -252,7 +252,7 @@ using var temp = TempFile.Create(".txt"); File.WriteAllText(temp, "content"); ``` -snippet source | anchor +snippet source | anchor @@ -267,7 +267,7 @@ using var temp = TempFile.Create(".txt", Encoding.UTF8); File.Exists(temp.Path); ``` -snippet source | anchor +snippet source | anchor @@ -283,7 +283,7 @@ using var temp = await TempFile.CreateText("Hello, World!"); var content = await File.ReadAllTextAsync(temp); Assert.Equal("Hello, World!", content); ``` -snippet source | anchor +snippet source | anchor @@ -304,7 +304,7 @@ using var temp = await TempFile.CreateText(json, ".json"); var content = await File.ReadAllTextAsync(temp); Assert.Equal(json, content); ``` -snippet source | anchor +snippet source | anchor @@ -323,7 +323,7 @@ using var temp = await TempFile.CreateText( var content = await File.ReadAllTextAsync(temp, Encoding.UTF8); Assert.Equal("Content with special chars: äöü", content); ``` -snippet source | anchor +snippet source | anchor @@ -341,7 +341,7 @@ using var temp = await TempFile.CreateBinary(data); var readData = await File.ReadAllBytesAsync(temp); Assert.Equal(data, readData); ``` -snippet source | anchor +snippet source | anchor @@ -353,7 +353,7 @@ Assert.Equal(data, readData); byte[] data = [0x01, 0x02, 0x03, 0x04]; using var temp = await TempFile.CreateBinary(data, ".bin"); ``` -snippet source | anchor +snippet source | anchor @@ -384,7 +384,7 @@ public void NoUsing() Debug.WriteLine(temp); } ``` -snippet source | anchor +snippet source | anchor The file can then be manually inspected. @@ -408,7 +408,7 @@ public void OpenExplorerAndDebug() temp.OpenExplorerAndDebug(); } ``` -snippet source | anchor +snippet source | anchor This method is designed to help debug tests by enabling the inspection of the contents of the temporary file while the test is paused. It performs two actions: