Skip to content

Commit 2b77b46

Browse files
fix: file stream sharing (#1363)
This PR adds stateful handling of file streams opened with the `FileShare.None` option. If a file stream is attempted to be opened whilst another file stream of the same file path and `FileShare.None` is in use (that is to say, has been opened and not yet disposed), an `IOException` will be thrown and the creation of the second file stream will be disallowed.
1 parent fb8d876 commit 2b77b46

15 files changed

+215
-34
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.IO;
4+
using System.IO.Abstractions.TestingHelpers;
5+
6+
public class FileHandles
7+
{
8+
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, (FileAccess access, FileShare share)>> handles = new();
9+
10+
public void AddHandle(string path, Guid guid, FileAccess access, FileShare share)
11+
{
12+
var pathHandles = handles.GetOrAdd(
13+
path,
14+
_ => new ConcurrentDictionary<Guid, (FileAccess, FileShare)>());
15+
16+
var requiredShare = AccessToShare(access);
17+
foreach (var (existingAccess, existingShare) in pathHandles.Values)
18+
{
19+
var existingRequiredShare = AccessToShare(existingAccess);
20+
var existingBlocksNew = (existingShare & requiredShare) != requiredShare;
21+
var newBlocksExisting = (share & existingRequiredShare) != existingRequiredShare;
22+
if (existingBlocksNew || newBlocksExisting)
23+
{
24+
throw CommonExceptions.ProcessCannotAccessFileInUse(path);
25+
}
26+
}
27+
28+
pathHandles[guid] = (access, share);
29+
}
30+
31+
public void RemoveHandle(string path, Guid guid)
32+
{
33+
if (handles.TryGetValue(path, out var pathHandles))
34+
{
35+
pathHandles.TryRemove(guid, out _);
36+
if (pathHandles.IsEmpty)
37+
{
38+
handles.TryRemove(path, out _);
39+
}
40+
}
41+
}
42+
43+
private static FileShare AccessToShare(FileAccess access)
44+
{
45+
var share = FileShare.None;
46+
if (access.HasFlag(FileAccess.Read))
47+
{
48+
share |= FileShare.Read;
49+
}
50+
if (access.HasFlag(FileAccess.Write))
51+
{
52+
share |= FileShare.Write;
53+
}
54+
return share;
55+
}
56+
}

src/TestableIO.System.IO.Abstractions.TestingHelpers/IMockFileDataAccessor.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,9 @@ public interface IMockFileDataAccessor : IFileSystem
110110
/// Gets a reference to the underlying file system.
111111
/// </summary>
112112
IFileSystem FileSystem { get; }
113+
114+
/// <summary>
115+
/// Gets a reference to the open file handles.
116+
/// </summary>
117+
FileHandles FileHandles { get; }
113118
}

src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFile.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ private FileSystemStream OpenInternal(
677677
}
678678
mockFileDataAccessor.AdjustTimes(mockFileData, timeAdjustments);
679679

680-
return new MockFileStream(mockFileDataAccessor, path, mode, access, options);
680+
return new MockFileStream(mockFileDataAccessor, path, mode, access, FileShare.Read, options);
681681
}
682682

683683
/// <inheritdoc />

src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileStream.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ public NullFileSystemStream() : base(Null, ".", true)
3131

3232
private readonly IMockFileDataAccessor mockFileDataAccessor;
3333
private readonly string path;
34+
private readonly Guid guid = Guid.NewGuid();
3435
private readonly FileAccess access = FileAccess.ReadWrite;
36+
private readonly FileShare share = FileShare.Read;
3537
private readonly FileOptions options;
3638
private readonly MockFileData fileData;
3739
private bool disposed;
@@ -42,6 +44,7 @@ public MockFileStream(
4244
string path,
4345
FileMode mode,
4446
FileAccess access = FileAccess.ReadWrite,
47+
FileShare share = FileShare.Read,
4548
FileOptions options = FileOptions.None)
4649
: base(new MemoryStream(),
4750
path == null ? null : Path.GetFullPath(path),
@@ -51,6 +54,7 @@ public MockFileStream(
5154
ThrowIfInvalidModeAccess(mode, access);
5255

5356
this.mockFileDataAccessor = mockFileDataAccessor ?? throw new ArgumentNullException(nameof(mockFileDataAccessor));
57+
path = mockFileDataAccessor.PathVerifier.FixPath(path);
5458
this.path = path;
5559
this.options = options;
5660

@@ -97,7 +101,9 @@ public MockFileStream(
97101
mockFileDataAccessor.AddFile(path, fileData);
98102
}
99103

104+
mockFileDataAccessor.FileHandles.AddHandle(path, guid, access, share);
100105
this.access = access;
106+
this.share = share;
101107
}
102108

103109
private static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access)
@@ -144,6 +150,7 @@ protected override void Dispose(bool disposing)
144150
{
145151
return;
146152
}
153+
mockFileDataAccessor.FileHandles.RemoveHandle(path, guid);
147154
InternalFlush();
148155
base.Dispose(disposing);
149156
OnClose();

src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileStreamFactory.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,25 @@ public FileSystemStream New(string path, FileMode mode, FileAccess access)
4141

4242
/// <inheritdoc />
4343
public FileSystemStream New(string path, FileMode mode, FileAccess access, FileShare share)
44-
=> new MockFileStream(mockFileSystem, path, mode, access);
44+
=> new MockFileStream(mockFileSystem, path, mode, access, share);
4545

4646
/// <inheritdoc />
4747
public FileSystemStream New(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize)
48-
=> new MockFileStream(mockFileSystem, path, mode, access);
48+
=> new MockFileStream(mockFileSystem, path, mode, access, share);
4949

5050
/// <inheritdoc />
5151
public FileSystemStream New(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize, bool useAsync)
52-
=> new MockFileStream(mockFileSystem, path, mode, access);
52+
=> new MockFileStream(mockFileSystem, path, mode, access, share);
5353

5454
/// <inheritdoc />
5555
public FileSystemStream New(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize,
5656
FileOptions options)
57-
=> new MockFileStream(mockFileSystem, path, mode, access, options);
57+
=> new MockFileStream(mockFileSystem, path, mode, access, share, options);
5858

5959
#if FEATURE_FILESTREAM_OPTIONS
6060
/// <inheritdoc />
6161
public FileSystemStream New(string path, FileStreamOptions options)
62-
=> new MockFileStream(mockFileSystem, path, options.Mode, options.Access, options.Options);
62+
=> new MockFileStream(mockFileSystem, path, options.Mode, options.Access, options.Share, options.Options);
6363
#endif
6464

6565
/// <inheritdoc />

src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystem.cs

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public class MockFileSystem : FileSystemBase, IMockFileDataAccessor
2121
private readonly PathVerifier pathVerifier;
2222
#if FEATURE_SERIALIZABLE
2323
[NonSerialized]
24+
#endif
25+
private readonly FileHandles fileHandles = new();
26+
#if FEATURE_SERIALIZABLE
27+
[NonSerialized]
2428
#endif
2529
private Func<DateTime> dateTimeProvider = defaultDateTimeProvider;
2630
private static Func<DateTime> defaultDateTimeProvider = () => DateTime.UtcNow;
@@ -114,6 +118,8 @@ public MockFileSystem(IDictionary<string, MockFileData> files, MockFileSystemOpt
114118
public IFileSystem FileSystem => this;
115119
/// <inheritdoc />
116120
public PathVerifier PathVerifier => pathVerifier;
121+
/// <inheritdoc />
122+
public FileHandles FileHandles => fileHandles;
117123

118124
/// <summary>
119125
/// Replaces the time provider with a mocked instance. This allows to influence the used time in tests.
@@ -128,19 +134,6 @@ public MockFileSystem MockTime(Func<DateTime> dateTimeProvider)
128134
return this;
129135
}
130136

131-
private string FixPath(string path, bool checkCaps = false)
132-
{
133-
if (path == null)
134-
{
135-
throw new ArgumentNullException(nameof(path), StringResources.Manager.GetString("VALUE_CANNOT_BE_NULL"));
136-
}
137-
138-
var pathSeparatorFixed = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
139-
var fullPath = Path.GetFullPath(pathSeparatorFixed);
140-
141-
return checkCaps ? GetPathWithCorrectDirectoryCapitalization(fullPath) : fullPath;
142-
}
143-
144137
//If C:\foo exists, ensures that trying to save a file to "C:\FOO\file.txt" instead saves it to "C:\foo\file.txt".
145138
private string GetPathWithCorrectDirectoryCapitalization(string fullPath)
146139
{
@@ -194,7 +187,7 @@ public MockFileData AdjustTimes(MockFileData fileData, TimeAdjustments timeAdjus
194187
/// <inheritdoc />
195188
public MockFileData GetFile(string path)
196189
{
197-
path = FixPath(path).TrimSlashes();
190+
path = pathVerifier.FixPath(path).TrimSlashes();
198191
return GetFileWithoutFixingPath(path);
199192
}
200193

@@ -210,7 +203,9 @@ public MockDriveData GetDrive(string name)
210203

211204
private void SetEntry(string path, MockFileData mockFile)
212205
{
213-
path = FixPath(path, true).TrimSlashes();
206+
path = GetPathWithCorrectDirectoryCapitalization(
207+
pathVerifier.FixPath(path)
208+
).TrimSlashes();
214209

215210
lock (files)
216211
{
@@ -232,7 +227,9 @@ private void SetEntry(string path, MockFileData mockFile)
232227
/// <inheritdoc />
233228
public void AddFile(string path, MockFileData mockFile, bool verifyAccess = true)
234229
{
235-
var fixedPath = FixPath(path, true);
230+
var fixedPath = GetPathWithCorrectDirectoryCapitalization(
231+
pathVerifier.FixPath(path)
232+
);
236233

237234
mockFile ??= new MockFileData(string.Empty);
238235
var file = GetFile(fixedPath);
@@ -319,7 +316,9 @@ public MockFileData GetFile(IFileInfo path)
319316
/// <inheritdoc />
320317
public void AddDirectory(string path)
321318
{
322-
var fixedPath = FixPath(path, true);
319+
var fixedPath = GetPathWithCorrectDirectoryCapitalization(
320+
pathVerifier.FixPath(path)
321+
);
323322
var separator = Path.DirectorySeparatorChar.ToString();
324323

325324
if (FileExists(fixedPath) && FileIsReadOnly(fixedPath))
@@ -408,8 +407,8 @@ public void AddDrive(string name, MockDriveData mockDrive)
408407
/// <inheritdoc />
409408
public void MoveDirectory(string sourcePath, string destPath)
410409
{
411-
sourcePath = FixPath(sourcePath);
412-
destPath = FixPath(destPath);
410+
sourcePath = pathVerifier.FixPath(sourcePath);
411+
destPath = pathVerifier.FixPath(destPath);
413412

414413
var sourcePathSequence = sourcePath.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
415414

@@ -452,7 +451,7 @@ bool PathStartsWith(string path, string[] minMatch)
452451
/// <inheritdoc />
453452
public void RemoveFile(string path, bool verifyAccess = true)
454453
{
455-
path = FixPath(path);
454+
path = pathVerifier.FixPath(path);
456455

457456
lock (files)
458457
{
@@ -473,7 +472,7 @@ public bool FileExists(string path)
473472
return false;
474473
}
475474

476-
path = FixPath(path).TrimSlashes();
475+
path = pathVerifier.FixPath(path).TrimSlashes();
477476

478477
lock (files)
479478
{

src/TestableIO.System.IO.Abstractions.TestingHelpers/PathVerifier.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,23 @@ public bool TryNormalizeDriveName(string name, out string result)
183183
result = name;
184184
return true;
185185
}
186+
187+
/// <summary>
188+
/// Resolves and normalizes a path.
189+
/// </summary>
190+
internal string FixPath(string path)
191+
{
192+
if (path == null)
193+
{
194+
throw new ArgumentNullException(nameof(path), StringResources.Manager.GetString("VALUE_CANNOT_BE_NULL"));
195+
}
196+
197+
var pathSeparatorFixed = path.Replace(
198+
_mockFileDataAccessor.Path.AltDirectorySeparatorChar,
199+
_mockFileDataAccessor.Path.DirectorySeparatorChar
200+
);
201+
var fullPath = _mockFileDataAccessor.Path.GetFullPath(pathSeparatorFixed);
202+
203+
return fullPath;
204+
}
186205
}

tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net10.0.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")]
22
[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")]
3+
public class FileHandles
4+
{
5+
public FileHandles() { }
6+
public void AddHandle(string path, System.Guid guid, System.IO.FileAccess access, System.IO.FileShare share) { }
7+
public void RemoveHandle(string path, System.Guid guid) { }
8+
}
39
namespace System.IO.Abstractions.TestingHelpers
410
{
511
public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem
@@ -8,6 +14,7 @@ namespace System.IO.Abstractions.TestingHelpers
814
System.Collections.Generic.IEnumerable<string> AllDrives { get; }
915
System.Collections.Generic.IEnumerable<string> AllFiles { get; }
1016
System.Collections.Generic.IEnumerable<string> AllPaths { get; }
17+
FileHandles FileHandles { get; }
1118
System.IO.Abstractions.IFileSystem FileSystem { get; }
1219
System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; }
1320
System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; }
@@ -384,7 +391,7 @@ namespace System.IO.Abstractions.TestingHelpers
384391
[System.Serializable]
385392
public class MockFileStream : System.IO.Abstractions.FileSystemStream, System.IO.Abstractions.IFileSystemAclSupport
386393
{
387-
public MockFileStream(System.IO.Abstractions.TestingHelpers.IMockFileDataAccessor mockFileDataAccessor, string path, System.IO.FileMode mode, System.IO.FileAccess access = 3, System.IO.FileOptions options = 0) { }
394+
public MockFileStream(System.IO.Abstractions.TestingHelpers.IMockFileDataAccessor mockFileDataAccessor, string path, System.IO.FileMode mode, System.IO.FileAccess access = 3, System.IO.FileShare share = 1, System.IO.FileOptions options = 0) { }
388395
public override bool CanRead { get; }
389396
public override bool CanWrite { get; }
390397
public static System.IO.Abstractions.FileSystemStream Null { get; }
@@ -440,6 +447,7 @@ namespace System.IO.Abstractions.TestingHelpers
440447
public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; }
441448
public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; }
442449
public override System.IO.Abstractions.IFile File { get; }
450+
public FileHandles FileHandles { get; }
443451
public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; }
444452
public override System.IO.Abstractions.IFileStreamFactory FileStream { get; }
445453
public System.IO.Abstractions.IFileSystem FileSystem { get; }

tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")]
22
[assembly: System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName=".NET Framework 4.7.2")]
3+
public class FileHandles
4+
{
5+
public FileHandles() { }
6+
public void AddHandle(string path, System.Guid guid, System.IO.FileAccess access, System.IO.FileShare share) { }
7+
public void RemoveHandle(string path, System.Guid guid) { }
8+
}
39
namespace System.IO.Abstractions.TestingHelpers
410
{
511
public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem
@@ -8,6 +14,7 @@ namespace System.IO.Abstractions.TestingHelpers
814
System.Collections.Generic.IEnumerable<string> AllDrives { get; }
915
System.Collections.Generic.IEnumerable<string> AllFiles { get; }
1016
System.Collections.Generic.IEnumerable<string> AllPaths { get; }
17+
FileHandles FileHandles { get; }
1118
System.IO.Abstractions.IFileSystem FileSystem { get; }
1219
System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; }
1320
System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; }
@@ -297,7 +304,7 @@ namespace System.IO.Abstractions.TestingHelpers
297304
[System.Serializable]
298305
public class MockFileStream : System.IO.Abstractions.FileSystemStream, System.IO.Abstractions.IFileSystemAclSupport
299306
{
300-
public MockFileStream(System.IO.Abstractions.TestingHelpers.IMockFileDataAccessor mockFileDataAccessor, string path, System.IO.FileMode mode, System.IO.FileAccess access = 3, System.IO.FileOptions options = 0) { }
307+
public MockFileStream(System.IO.Abstractions.TestingHelpers.IMockFileDataAccessor mockFileDataAccessor, string path, System.IO.FileMode mode, System.IO.FileAccess access = 3, System.IO.FileShare share = 1, System.IO.FileOptions options = 0) { }
301308
public override bool CanRead { get; }
302309
public override bool CanWrite { get; }
303310
public static System.IO.Abstractions.FileSystemStream Null { get; }
@@ -347,6 +354,7 @@ namespace System.IO.Abstractions.TestingHelpers
347354
public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; }
348355
public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; }
349356
public override System.IO.Abstractions.IFile File { get; }
357+
public FileHandles FileHandles { get; }
350358
public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; }
351359
public override System.IO.Abstractions.IFileStreamFactory FileStream { get; }
352360
public System.IO.Abstractions.IFileSystem FileSystem { get; }

tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")]
22
[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName=".NET 6.0")]
3+
public class FileHandles
4+
{
5+
public FileHandles() { }
6+
public void AddHandle(string path, System.Guid guid, System.IO.FileAccess access, System.IO.FileShare share) { }
7+
public void RemoveHandle(string path, System.Guid guid) { }
8+
}
39
namespace System.IO.Abstractions.TestingHelpers
410
{
511
public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem
@@ -8,6 +14,7 @@ namespace System.IO.Abstractions.TestingHelpers
814
System.Collections.Generic.IEnumerable<string> AllDrives { get; }
915
System.Collections.Generic.IEnumerable<string> AllFiles { get; }
1016
System.Collections.Generic.IEnumerable<string> AllPaths { get; }
17+
FileHandles FileHandles { get; }
1118
System.IO.Abstractions.IFileSystem FileSystem { get; }
1219
System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; }
1320
System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; }
@@ -346,7 +353,7 @@ namespace System.IO.Abstractions.TestingHelpers
346353
[System.Serializable]
347354
public class MockFileStream : System.IO.Abstractions.FileSystemStream, System.IO.Abstractions.IFileSystemAclSupport
348355
{
349-
public MockFileStream(System.IO.Abstractions.TestingHelpers.IMockFileDataAccessor mockFileDataAccessor, string path, System.IO.FileMode mode, System.IO.FileAccess access = 3, System.IO.FileOptions options = 0) { }
356+
public MockFileStream(System.IO.Abstractions.TestingHelpers.IMockFileDataAccessor mockFileDataAccessor, string path, System.IO.FileMode mode, System.IO.FileAccess access = 3, System.IO.FileShare share = 1, System.IO.FileOptions options = 0) { }
350357
public override bool CanRead { get; }
351358
public override bool CanWrite { get; }
352359
public static System.IO.Abstractions.FileSystemStream Null { get; }
@@ -402,6 +409,7 @@ namespace System.IO.Abstractions.TestingHelpers
402409
public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; }
403410
public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; }
404411
public override System.IO.Abstractions.IFile File { get; }
412+
public FileHandles FileHandles { get; }
405413
public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; }
406414
public override System.IO.Abstractions.IFileStreamFactory FileStream { get; }
407415
public System.IO.Abstractions.IFileSystem FileSystem { get; }

0 commit comments

Comments
 (0)