Skip to content

Commit 31a3aba

Browse files
authored
fix: throw correct exception when ACL denies access (#832)
This PR fixes an issue where ACL-denied access operations were throwing `IOException` instead of the correct `UnauthorizedAccessException` on Windows. The change ensures proper exception handling when trying to create directories or write files in directories that are denied access by Windows ACL. - Fixes the exception type thrown when ACL denies access from `IOException` to `UnauthorizedAccessException` - Updates the storage container interface to pass location context for better error reporting
1 parent cbf2333 commit 31a3aba

File tree

10 files changed

+106
-17
lines changed

10 files changed

+106
-17
lines changed

Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ internal static UnauthorizedAccessException AccessToPathDenied(string path = "")
1616
#endif
1717
};
1818

19-
internal static IOException AclAccessToPathDenied(string path)
20-
=> new($"Access to the path '{path}' is denied.");
21-
2219
internal static ArgumentException AppendAccessOnlyInWriteOnlyMode(
2320
string paramName = "access")
2421
=> new($"{nameof(FileMode.Append)} access can be requested only in write-only mode.",

Source/Testably.Abstractions.Testing/Storage/IStorageContainer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ IStorageAccessHandle RequestAccess(FileAccess access, FileShare share,
9090
bool deleteAccess = false,
9191
bool ignoreFileShare = false,
9292
bool ignoreMetadataErrors = true,
93-
int? hResult = null);
93+
int? hResult = null,
94+
IStorageLocation? onBehalfOfLocation = null);
9495

9596
/// <summary>
9697
/// Writes the <paramref name="bytes" /> to the <see cref="IFileInfo" />.

Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,13 @@ public void Encrypt()
163163
/// <inheritdoc cref="IStorageContainer.GetBytes()" />
164164
public byte[] GetBytes() => _bytes;
165165

166-
/// <inheritdoc cref="IStorageContainer.RequestAccess(FileAccess, FileShare, bool, bool, bool, int?)" />
166+
/// <inheritdoc cref="IStorageContainer.RequestAccess(FileAccess, FileShare, bool, bool, bool, int?, IStorageLocation?)" />
167167
public IStorageAccessHandle RequestAccess(FileAccess access, FileShare share,
168168
bool deleteAccess = false,
169169
bool ignoreFileShare = false,
170170
bool ignoreMetadataErrors = true,
171-
int? hResult = null)
171+
int? hResult = null,
172+
IStorageLocation? onBehalfOfLocation = null)
172173
{
173174
if (FileSystemRegistration.IsInitializing())
174175
{
@@ -202,7 +203,7 @@ public IStorageAccessHandle RequestAccess(FileAccess access, FileShare share,
202203
if (!_fileSystem.AccessControlStrategy
203204
.IsAccessGranted(_location.FullPath, Extensibility))
204205
{
205-
throw ExceptionFactory.AclAccessToPathDenied(_location.FullPath);
206+
throw ExceptionFactory.AccessToPathDenied((onBehalfOfLocation ?? _location).FullPath);
206207
}
207208

208209
if (_fileSystem.Storage.TryGetFileAccess(

Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ private void CheckAndAdjustParentDirectoryTimes(IStorageLocation location)
758758
throw ExceptionFactory.UnixFileModeAccessDenied(location.FullPath);
759759
}
760760
#else
761-
using (parentContainer.RequestAccess(FileAccess.Write, FileShare.ReadWrite))
761+
using (parentContainer.RequestAccess(FileAccess.Write, FileShare.ReadWrite, onBehalfOfLocation: location))
762762
{
763763
TimeAdjustments timeAdjustment = TimeAdjustments.LastWriteTime;
764764
if (_fileSystem.Execute.IsWindows)

Source/Testably.Abstractions.Testing/Storage/NullContainer.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,13 @@ public void Encrypt()
9696
public byte[] GetBytes()
9797
=> Array.Empty<byte>();
9898

99-
/// <inheritdoc cref="IStorageContainer.RequestAccess(FileAccess, FileShare, bool, bool, bool, int?)" />
99+
/// <inheritdoc cref="IStorageContainer.RequestAccess(FileAccess, FileShare, bool, bool, bool, int?, IStorageLocation?)" />
100100
public IStorageAccessHandle RequestAccess(FileAccess access, FileShare share,
101101
bool deleteAccess = false,
102102
bool ignoreFileShare = false,
103103
bool ignoreMetadataErrors = true,
104-
int? hResult = null)
104+
int? hResult = null,
105+
IStorageLocation? onBehalfOfLocation = null)
105106
=> new NullStorageAccessHandle(access, share, deleteAccess);
106107

107108
/// <inheritdoc cref="IStorageContainer.WriteBytes(byte[])" />

Tests/Testably.Abstractions.Testing.Tests/MockFileSystemTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ public async Task ToString_ShouldContainStorageInformation()
120120

121121
[Theory]
122122
[AutoData]
123-
public async Task WithAccessControl_Denied_CreateDirectoryShouldThrowIOException(
123+
public async Task WithAccessControl_Denied_CreateDirectoryShouldThrowUnauthorizedAccessException(
124124
string path)
125125
{
126126
Skip.If(!Test.RunsOnWindows);
@@ -134,7 +134,7 @@ public async Task WithAccessControl_Denied_CreateDirectoryShouldThrowIOException
134134
sut.Directory.CreateDirectory(path);
135135
});
136136

137-
await That(exception).IsExactly<IOException>();
137+
await That(exception).IsExactly<UnauthorizedAccessException>();
138138
}
139139

140140
[Theory]
@@ -155,7 +155,7 @@ public async Task WithAccessControl_ShouldConsiderPath(
155155
sut.Directory.CreateDirectory(deniedPath);
156156
});
157157

158-
await That(exception).IsExactly<IOException>();
158+
await That(exception).IsExactly<UnauthorizedAccessException>();
159159
}
160160

161161
[Fact]

Tests/Testably.Abstractions.Testing.Tests/TestHelpers/LockableContainer.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Testably.Abstractions.Testing.Tests.TestHelpers;
1212
/// A <see cref="IStorageContainer" /> for testing purposes.
1313
/// <para />
1414
/// Set <see cref="IsLocked" /> to <see langword="true" /> to simulate a locked file
15-
/// (<see cref="RequestAccess(FileAccess, FileShare, bool, bool, bool, int?)" /> throws an <see cref="IOException" />).
15+
/// (<see cref="RequestAccess(FileAccess, FileShare, bool, bool, bool, int?, IStorageLocation?)" /> throws an <see cref="IOException" />).
1616
/// </summary>
1717
internal sealed class LockableContainer(
1818
MockFileSystem fileSystem,
@@ -24,7 +24,7 @@ internal sealed class LockableContainer(
2424

2525
/// <summary>
2626
/// Simulate a locked file, if set to <see langword="true" />.<br />
27-
/// In this case <see cref="RequestAccess(FileAccess, FileShare, bool, bool, bool, int?)" /> throws
27+
/// In this case <see cref="RequestAccess(FileAccess, FileShare, bool, bool, bool, int?, IStorageLocation?)" /> throws
2828
/// an <see cref="IOException" />, otherwise it will succeed.
2929
/// </summary>
3030
public bool IsLocked { get; set; }
@@ -95,12 +95,13 @@ public void Encrypt()
9595
public byte[] GetBytes()
9696
=> _bytes;
9797

98-
/// <inheritdoc cref="IStorageContainer.RequestAccess(FileAccess, FileShare, bool, bool, bool, int?)" />
98+
/// <inheritdoc cref="IStorageContainer.RequestAccess(FileAccess, FileShare, bool, bool, bool, int?, IStorageLocation?)" />
9999
public IStorageAccessHandle RequestAccess(FileAccess access, FileShare share,
100100
bool deleteAccess = false,
101101
bool ignoreFileShare = false,
102102
bool ignoreMetadataErrors = true,
103-
int? hResult = null)
103+
int? hResult = null,
104+
IStorageLocation? onBehalfOfLocation = null)
104105
{
105106
if (IsLocked)
106107
{

Tests/Testably.Abstractions.Tests/FileSystem/Directory/CreateDirectoryTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.IO;
2+
using Testably.Abstractions.Testing.FileSystem;
23

34
namespace Testably.Abstractions.Tests.FileSystem.Directory;
45

@@ -270,6 +271,34 @@ await That(result.FullName).IsEqualTo(System.IO.Path.Combine(BasePath, expectedN
270271
}
271272
#endif
272273

274+
[Fact]
275+
public async Task
276+
CreateDirectory_WithoutAccessRightsToParent_ShouldThrowUnauthorizedAccessException()
277+
{
278+
Skip.IfNot(Test.RunsOnWindows);
279+
280+
string restrictedDirectory = @"C:\Windows\WaaS";
281+
if (FileSystem is MockFileSystem mockFileSystem)
282+
{
283+
restrictedDirectory = @"C:\Restricted directory";
284+
mockFileSystem.Directory.CreateDirectory(restrictedDirectory);
285+
mockFileSystem.WithAccessControlStrategy(
286+
new DefaultAccessControlStrategy((p, _)
287+
=> !restrictedDirectory.Equals(p, StringComparison.Ordinal)));
288+
}
289+
290+
string path = FileSystem.Path.Combine(restrictedDirectory, "my-subdirectory");
291+
292+
void Act()
293+
{
294+
FileSystem.Directory.CreateDirectory(path);
295+
}
296+
297+
await That(Act).Throws<UnauthorizedAccessException>()
298+
.WithHResult(-2147024891).And
299+
.WithMessage($"Access to the path '{path}' is denied.");
300+
}
301+
273302
#if NETFRAMEWORK
274303
[Theory]
275304
[InlineData("")]

Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/CreateTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.IO;
2+
using Testably.Abstractions.Testing.FileSystem;
23

34
namespace Testably.Abstractions.Tests.FileSystem.DirectoryInfo;
45

@@ -134,4 +135,33 @@ await That(result.FullName).IsEqualTo(FileSystem.Path.Combine(BasePath, expected
134135
FileSystem.Path.DirectorySeparatorChar)));
135136
await That(FileSystem.Directory.Exists(nameWithSuffix)).IsTrue();
136137
}
138+
139+
[Fact]
140+
public async Task
141+
CreateDirectory_WithoutAccessRightsToParent_ShouldThrowUnauthorizedAccessException()
142+
{
143+
Skip.IfNot(Test.RunsOnWindows);
144+
145+
string restrictedDirectory = @"C:\Windows\WaaS";
146+
if (FileSystem is MockFileSystem mockFileSystem)
147+
{
148+
restrictedDirectory = @"C:\Restricted directory";
149+
mockFileSystem.Directory.CreateDirectory(restrictedDirectory);
150+
mockFileSystem.WithAccessControlStrategy(
151+
new DefaultAccessControlStrategy((p, _)
152+
=> !restrictedDirectory.Equals(p, StringComparison.Ordinal)));
153+
}
154+
155+
string path = FileSystem.Path.Combine(restrictedDirectory, "my-subdirectory");
156+
IDirectoryInfo sut = FileSystem.DirectoryInfo.New(path);
157+
158+
void Act()
159+
{
160+
sut.Create();
161+
}
162+
163+
await That(Act).Throws<UnauthorizedAccessException>()
164+
.WithHResult(-2147024891).And
165+
.WithMessage($"Access to the path '{path}' is denied.");
166+
}
137167
}

Tests/Testably.Abstractions.Tests/FileSystem/File/WriteAllTextTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.IO;
22
using System.Text;
3+
using Testably.Abstractions.Testing.FileSystem;
34

45
namespace Testably.Abstractions.Tests.FileSystem.File;
56

@@ -167,6 +168,34 @@ void Act()
167168
await That(Act).Throws<UnauthorizedAccessException>().WithHResult(-2147024891);
168169
}
169170

171+
[Fact]
172+
public async Task
173+
WriteAllText_WithoutAccessRightsToParentDirectory_ShouldThrowUnauthorizedAccessException()
174+
{
175+
Skip.IfNot(Test.RunsOnWindows);
176+
177+
string restrictedDirectory = @"C:\Windows\WaaS";
178+
if (FileSystem is MockFileSystem mockFileSystem)
179+
{
180+
restrictedDirectory = @"C:\Restricted directory";
181+
mockFileSystem.Directory.CreateDirectory(restrictedDirectory);
182+
mockFileSystem.WithAccessControlStrategy(
183+
new DefaultAccessControlStrategy((p, _)
184+
=> !restrictedDirectory.Equals(p, StringComparison.Ordinal)));
185+
}
186+
187+
string path = FileSystem.Path.Combine(restrictedDirectory, "my-file.txt");
188+
189+
void Act()
190+
{
191+
FileSystem.File.WriteAllText(path, "some-content");
192+
}
193+
194+
await That(Act).Throws<UnauthorizedAccessException>()
195+
.WithHResult(-2147024891).And
196+
.WithMessage($"Access to the path '{path}' is denied.");
197+
}
198+
170199
#if FEATURE_FILE_SPAN
171200
[Theory]
172201
[AutoData]

0 commit comments

Comments
 (0)