From a1a1154f7bf3a029ce8551820e862bee70a786bd Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Tue, 28 Jul 2026 04:52:41 +0100 Subject: [PATCH 1/3] fix(manifest): continue past unavailable directories --- .../Manifest/ManifestDiscoveryServiceTests.cs | 51 ++++++++++ .../Manifest/ManifestDiscoveryService.cs | 96 ++++++++++++++++++- 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs index 81b6c547..40f096c4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs @@ -143,6 +143,57 @@ await File.WriteAllTextAsync( Assert.Equal(nestedManifestId, manifest.Key); } + /// + /// Tests that unavailable descendants do not prevent discovery in accessible sibling directories. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DiscoverManifestsAsync_WithUnavailableDescendants_ContinuesDiscoveringAccessibleManifests() + { + // Arrange + const string accessibleManifestId = "1.0.genhub.mod.accessible"; + var accessibleDirectory = Directory.CreateDirectory( + Path.Combine(_tempDirectory, "accessible")).FullName; + var inaccessibleDirectory = Directory.CreateDirectory( + Path.Combine(_tempDirectory, "inaccessible")).FullName; + var removedDirectory = Directory.CreateDirectory( + Path.Combine(_tempDirectory, "removed")).FullName; + await File.WriteAllTextAsync( + Path.Combine(accessibleDirectory, "accessible.json"), + SerializeManifest(accessibleManifestId)); + + IEnumerable EnumerateFiles(string directory, string pattern) + { + if (directory == inaccessibleDirectory) + { + throw new UnauthorizedAccessException("Injected inaccessible directory."); + } + + if (directory == removedDirectory) + { + throw new DirectoryNotFoundException("Injected concurrently removed directory."); + } + + return Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly); + } + + var discoveryService = new ManifestDiscoveryService( + _loggerMock.Object, + _cacheMock.Object, + EnumerateFiles, + directory => Directory.EnumerateDirectories( + directory, + "*", + SearchOption.TopDirectoryOnly)); + + // Act + var manifests = await discoveryService.DiscoverManifestsAsync([_tempDirectory]); + + // Assert + var manifest = Assert.Single(manifests); + Assert.Equal(accessibleManifestId, manifest.Key); + } + /// /// Tests that ValidateDependencies returns false when a required dependency is missing. /// diff --git a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs index 9691169b..2b4c945b 100644 --- a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs +++ b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs @@ -20,6 +20,29 @@ namespace GenHub.Features.Manifest; public class ManifestDiscoveryService(ILogger logger, IManifestCache manifestCache) { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly Func> _enumerateFiles = + (directory, pattern) => Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly); + + private readonly Func> _enumerateDirectories = + directory => Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly); + + /// + /// Initializes a new instance of the class with filesystem test seams. + /// + /// The logger. + /// The manifest cache. + /// The top-level file enumerator. + /// The top-level directory enumerator. + internal ManifestDiscoveryService( + ILogger logger, + IManifestCache manifestCache, + Func> enumerateFiles, + Func> enumerateDirectories) + : this(logger, manifestCache) + { + _enumerateFiles = enumerateFiles; + _enumerateDirectories = enumerateDirectories; + } /// /// Gets manifests by content type. @@ -61,7 +84,10 @@ public async Task> DiscoverManifestsAsync( foreach (var directory in searchDirectories.Where(Directory.Exists)) { logger.LogInformation("Scanning directory for manifests: {Directory}", directory); - var manifestFiles = Directory.EnumerateFiles(directory, FileTypes.JsonFilePattern, SearchOption.AllDirectories); + var manifestFiles = EnumerateFilesSafely( + directory, + FileTypes.JsonFilePattern, + cancellationToken); foreach (var manifestFile in manifestFiles) { try @@ -157,6 +183,11 @@ public bool ValidateDependencies( return true; } + private static bool IsSkippableEnumerationException(Exception exception) + { + return exception is UnauthorizedAccessException or IOException; + } + private static bool IsVersionCompatible(string actualVersion, string minVersion, string maxVersion) { if (!string.IsNullOrEmpty(minVersion) && string.Compare(actualVersion, minVersion, StringComparison.OrdinalIgnoreCase) < 0) @@ -184,16 +215,71 @@ private static bool IsVersionCompatible(string actualVersion, string minVersion, return null; } + private IEnumerable EnumerateFilesSafely( + string rootDirectory, + string searchPattern, + CancellationToken cancellationToken) + { + var pendingDirectories = new Stack(); + pendingDirectories.Push(rootDirectory); + + while (pendingDirectories.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var currentDirectory = pendingDirectories.Pop(); + + string[] files; + try + { + files = _enumerateFiles(currentDirectory, searchPattern).ToArray(); + } + catch (Exception ex) when (IsSkippableEnumerationException(ex)) + { + logger.LogWarning( + ex, + "Skipping files in inaccessible or unavailable manifest directory: {Directory}", + currentDirectory); + files = []; + } + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return file; + } + + string[] childDirectories; + try + { + childDirectories = _enumerateDirectories(currentDirectory).ToArray(); + } + catch (Exception ex) when (IsSkippableEnumerationException(ex)) + { + logger.LogWarning( + ex, + "Skipping inaccessible or unavailable manifest directory: {Directory}", + currentDirectory); + childDirectories = []; + } + + for (var index = childDirectories.Length - 1; index >= 0; index--) + { + pendingDirectories.Push(childDirectories[index]); + } + } + } + private async Task DiscoverFileSystemManifestsAsync(IEnumerable searchDirectories, CancellationToken cancellationToken) { foreach (var directory in searchDirectories.Where(Directory.Exists)) { logger.LogInformation("Scanning directory for manifests: {Directory}", directory); - // Look for both .json and .manifest.json files to avoid conflicts with stored manifests - var manifestFiles = Directory.EnumerateFiles(directory, FileTypes.ManifestFilePattern, SearchOption.AllDirectories) - .Concat(Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories) - .Where(f => !f.EndsWith(FileTypes.ManifestFileExtension))); + // The JSON pattern includes both .json and .manifest.json files. + var manifestFiles = EnumerateFilesSafely( + directory, + FileTypes.JsonFilePattern, + cancellationToken); foreach (var manifestFile in manifestFiles) { From 2740efd32ceeb024874dd85d7646e1ebda3d2831 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Tue, 28 Jul 2026 11:47:44 +0100 Subject: [PATCH 2/3] fix(manifest): use default directory enumeration pattern --- GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs index 2b4c945b..e272862e 100644 --- a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs +++ b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs @@ -24,7 +24,7 @@ public class ManifestDiscoveryService(ILogger logger, (directory, pattern) => Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly); private readonly Func> _enumerateDirectories = - directory => Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly); + directory => Directory.EnumerateDirectories(directory); /// /// Initializes a new instance of the class with filesystem test seams. From ace7451f9892bdd1a85ffba3ebefda84a4c1ad66 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 29 Jul 2026 13:51:17 +0100 Subject: [PATCH 3/3] test(manifest): cover directory enumeration failures during discovery --- .../Manifest/ManifestDiscoveryServiceTests.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs index 40f096c4..55ff00df 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs @@ -158,9 +158,16 @@ public async Task DiscoverManifestsAsync_WithUnavailableDescendants_ContinuesDis Path.Combine(_tempDirectory, "inaccessible")).FullName; var removedDirectory = Directory.CreateDirectory( Path.Combine(_tempDirectory, "removed")).FullName; + var unlistableDirectory = Directory.CreateDirectory( + Path.Combine(_tempDirectory, "unlistable")).FullName; + var unreachableDirectory = Directory.CreateDirectory( + Path.Combine(unlistableDirectory, "unreachable")).FullName; await File.WriteAllTextAsync( Path.Combine(accessibleDirectory, "accessible.json"), SerializeManifest(accessibleManifestId)); + await File.WriteAllTextAsync( + Path.Combine(unreachableDirectory, "unreachable.json"), + SerializeManifest("1.0.genhub.mod.unreachable")); IEnumerable EnumerateFiles(string directory, string pattern) { @@ -177,14 +184,21 @@ IEnumerable EnumerateFiles(string directory, string pattern) return Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly); } + IEnumerable EnumerateDirectories(string directory) + { + if (directory == unlistableDirectory) + { + throw new UnauthorizedAccessException("Injected unlistable directory."); + } + + return Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly); + } + var discoveryService = new ManifestDiscoveryService( _loggerMock.Object, _cacheMock.Object, EnumerateFiles, - directory => Directory.EnumerateDirectories( - directory, - "*", - SearchOption.TopDirectoryOnly)); + EnumerateDirectories); // Act var manifests = await discoveryService.DiscoverManifestsAsync([_tempDirectory]);