|
3 | 3 | using System; |
4 | 4 | using System.IO; |
5 | 5 | using System.Runtime.InteropServices; |
| 6 | +using System.Security.AccessControl; |
| 7 | +using System.Security.Principal; |
6 | 8 | using System.Threading.Tasks; |
7 | 9 | using Microsoft.SemanticKernel; |
8 | 10 | using Microsoft.SemanticKernel.Plugins.Core; |
@@ -111,6 +113,191 @@ public async Task ItDeniesAllPathsWithDefaultConfigAsync() |
111 | 113 | await Assert.ThrowsAsync<InvalidOperationException>(async () => await plugin.WriteAsync(path, "hello world")); |
112 | 114 | } |
113 | 115 |
|
| 116 | + [Fact] |
| 117 | + public async Task ItDoesNotRevealReadOnlyOrExistenceOfDisallowedFilesAsync() |
| 118 | + { |
| 119 | + // Arrange |
| 120 | + var tempDir = Path.Combine(Path.GetTempPath(), $"FileIOPluginTests_{Guid.NewGuid():N}"); |
| 121 | + var allowedDir = Path.Combine(tempDir, "allowed"); |
| 122 | + var outsideDir = Path.Combine(tempDir, "outside"); |
| 123 | + Directory.CreateDirectory(allowedDir); |
| 124 | + Directory.CreateDirectory(outsideDir); |
| 125 | + |
| 126 | + var readOnlyFile = Path.Combine(outsideDir, "readonly.txt"); |
| 127 | + var missingFile = Path.Combine(outsideDir, "missing.txt"); |
| 128 | + await File.WriteAllTextAsync(readOnlyFile, "secret"); |
| 129 | + File.SetAttributes(readOnlyFile, FileAttributes.ReadOnly); |
| 130 | + |
| 131 | + try |
| 132 | + { |
| 133 | + foreach (var plugin in new[] |
| 134 | + { |
| 135 | + new FileIOPlugin(), |
| 136 | + new FileIOPlugin() { AllowedFolders = [allowedDir], DisableFileOverwrite = false } |
| 137 | + }) |
| 138 | + { |
| 139 | + // Act |
| 140 | + var readOnlyReadEx = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(readOnlyFile)); |
| 141 | + var missingReadEx = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(missingFile)); |
| 142 | + var readOnlyWriteEx = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(readOnlyFile, "changed")); |
| 143 | + var missingWriteEx = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(missingFile, "changed")); |
| 144 | + |
| 145 | + // Assert - responses for existing read-only and missing files must be indistinguishable |
| 146 | + Assert.Equal(missingReadEx.Message, readOnlyReadEx.Message); |
| 147 | + Assert.Equal(missingWriteEx.Message, readOnlyWriteEx.Message); |
| 148 | + Assert.DoesNotContain(outsideDir, readOnlyReadEx.Message, StringComparison.OrdinalIgnoreCase); |
| 149 | + Assert.DoesNotContain(outsideDir, readOnlyWriteEx.Message, StringComparison.OrdinalIgnoreCase); |
| 150 | + } |
| 151 | + } |
| 152 | + finally |
| 153 | + { |
| 154 | + File.SetAttributes(readOnlyFile, FileAttributes.Normal); |
| 155 | + TryDeleteDirectory(tempDir); |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + [Fact] |
| 160 | + public async Task ItDoesNotIncludePathInReadOnlyExceptionAsync() |
| 161 | + { |
| 162 | + // Arrange |
| 163 | + var plugin = new FileIOPlugin() |
| 164 | + { |
| 165 | + AllowedFolders = [Path.GetTempPath()], |
| 166 | + DisableFileOverwrite = false |
| 167 | + }; |
| 168 | + var path = Path.GetTempFileName(); |
| 169 | + File.SetAttributes(path, FileAttributes.ReadOnly); |
| 170 | + |
| 171 | + try |
| 172 | + { |
| 173 | + // Act |
| 174 | + var ex = await Assert.ThrowsAsync<UnauthorizedAccessException>(() => plugin.WriteAsync(path, "hello world")); |
| 175 | + |
| 176 | + // Assert |
| 177 | + Assert.DoesNotContain(Path.GetFileName(path), ex.Message, StringComparison.OrdinalIgnoreCase); |
| 178 | + } |
| 179 | + finally |
| 180 | + { |
| 181 | + File.SetAttributes(path, FileAttributes.Normal); |
| 182 | + File.Delete(path); |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + [Fact] |
| 187 | + public async Task ItDeniesUnresolvablePathsWithoutHidingConfigurationErrorsAsync() |
| 188 | + { |
| 189 | + var tempDir = Path.Combine(Path.GetTempPath(), $"FileIOPluginTests_{Guid.NewGuid():N}"); |
| 190 | + var allowedDir = Path.Combine(tempDir, "allowed"); |
| 191 | + var outsideDir = Path.Combine(tempDir, "outside"); |
| 192 | + Directory.CreateDirectory(allowedDir); |
| 193 | + Directory.CreateDirectory(outsideDir); |
| 194 | + var linkPath = Path.Combine(outsideDir, "loop"); |
| 195 | + |
| 196 | + try |
| 197 | + { |
| 198 | + try |
| 199 | + { |
| 200 | + Directory.CreateSymbolicLink(linkPath, linkPath); |
| 201 | + } |
| 202 | + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) |
| 203 | + { |
| 204 | + // Skip if this environment does not permit symbolic link creation. |
| 205 | + return; |
| 206 | + } |
| 207 | + |
| 208 | + var resolutionError = Assert.Throws<InvalidOperationException>(() => PathUtilities.GetSafeFullPath(linkPath)); |
| 209 | + var plugin = new FileIOPlugin() { AllowedFolders = [allowedDir] }; |
| 210 | + var missingPath = Path.Combine(outsideDir, "missing.txt"); |
| 211 | + var unresolvablePath = Path.Combine(linkPath, "file.txt"); |
| 212 | + |
| 213 | + var expectedReadError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(missingPath)); |
| 214 | + var expectedWriteError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(missingPath, "changed")); |
| 215 | + var readError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(unresolvablePath)); |
| 216 | + var writeError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(unresolvablePath, "changed")); |
| 217 | + |
| 218 | + Assert.Equal(expectedReadError.Message, readError.Message); |
| 219 | + Assert.Equal(expectedWriteError.Message, writeError.Message); |
| 220 | + |
| 221 | + plugin.AllowedFolders = [linkPath]; |
| 222 | + var regularPath = Path.Combine(allowedDir, "missing.txt"); |
| 223 | + var configurationReadError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(regularPath)); |
| 224 | + var configurationWriteError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(regularPath, "changed")); |
| 225 | + Assert.Equal(resolutionError.Message, configurationReadError.Message); |
| 226 | + Assert.Equal(resolutionError.Message, configurationWriteError.Message); |
| 227 | + } |
| 228 | + finally |
| 229 | + { |
| 230 | + TryDeleteDirectory(tempDir); |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + [Fact] |
| 235 | + public async Task ItDeniesInaccessiblePathsWithoutHidingConfigurationErrorsAsync() |
| 236 | + { |
| 237 | + if (!OperatingSystem.IsWindows()) |
| 238 | + { |
| 239 | + return; |
| 240 | + } |
| 241 | + |
| 242 | + var tempDir = Path.Combine(Path.GetTempPath(), $"FileIOPluginTests_{Guid.NewGuid():N}"); |
| 243 | + var allowedDir = Path.Combine(tempDir, "allowed"); |
| 244 | + var outsideDir = Path.Combine(tempDir, "outside"); |
| 245 | + Directory.CreateDirectory(allowedDir); |
| 246 | + Directory.CreateDirectory(outsideDir); |
| 247 | + var restrictedDir = Directory.CreateDirectory(Path.Combine(outsideDir, "restricted")); |
| 248 | + var restrictedRoot = Directory.CreateDirectory(Path.Combine(restrictedDir.FullName, "nested")); |
| 249 | + var inaccessiblePath = Path.Combine(restrictedRoot.FullName, "file.txt"); |
| 250 | + await File.WriteAllTextAsync(inaccessiblePath, "secret"); |
| 251 | + |
| 252 | + var restrictedSecurity = restrictedDir.GetAccessControl(AccessControlSections.Access); |
| 253 | + using var identity = WindowsIdentity.GetCurrent(); |
| 254 | + Assert.NotNull(identity.User); |
| 255 | + var denyRule = new FileSystemAccessRule( |
| 256 | + identity.User, |
| 257 | + FileSystemRights.Read, |
| 258 | + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, |
| 259 | + PropagationFlags.None, |
| 260 | + AccessControlType.Deny); |
| 261 | + restrictedSecurity.AddAccessRule(denyRule); |
| 262 | + |
| 263 | + try |
| 264 | + { |
| 265 | + restrictedDir.SetAccessControl(restrictedSecurity); |
| 266 | + try |
| 267 | + { |
| 268 | + PathUtilities.GetSafeFullPath(inaccessiblePath); |
| 269 | + |
| 270 | + // Skip if this environment does not enforce the deny rule (e.g. elevated processes). |
| 271 | + return; |
| 272 | + } |
| 273 | + catch (UnauthorizedAccessException) |
| 274 | + { |
| 275 | + } |
| 276 | + |
| 277 | + var plugin = new FileIOPlugin() { AllowedFolders = [allowedDir] }; |
| 278 | + var missingPath = Path.Combine(outsideDir, "missing.txt"); |
| 279 | + |
| 280 | + var expectedReadError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(missingPath)); |
| 281 | + var expectedWriteError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(missingPath, "changed")); |
| 282 | + var readError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.ReadAsync(inaccessiblePath)); |
| 283 | + var writeError = await Assert.ThrowsAsync<InvalidOperationException>(() => plugin.WriteAsync(inaccessiblePath, "changed")); |
| 284 | + |
| 285 | + Assert.Equal(expectedReadError.Message, readError.Message); |
| 286 | + Assert.Equal(expectedWriteError.Message, writeError.Message); |
| 287 | + |
| 288 | + plugin.AllowedFolders = [restrictedRoot.FullName]; |
| 289 | + var regularPath = Path.Combine(allowedDir, "missing.txt"); |
| 290 | + await Assert.ThrowsAsync<UnauthorizedAccessException>(() => plugin.ReadAsync(regularPath)); |
| 291 | + await Assert.ThrowsAsync<UnauthorizedAccessException>(() => plugin.WriteAsync(regularPath, "changed")); |
| 292 | + } |
| 293 | + finally |
| 294 | + { |
| 295 | + restrictedSecurity.RemoveAccessRule(denyRule); |
| 296 | + restrictedDir.SetAccessControl(restrictedSecurity); |
| 297 | + TryDeleteDirectory(tempDir); |
| 298 | + } |
| 299 | + } |
| 300 | + |
114 | 301 | [Fact] |
115 | 302 | public async Task ItCannotWriteToDisallowedFoldersAsync() |
116 | 303 | { |
|
0 commit comments