From 658c2131bbaad0b554fda5c5f937f75d0c8a3a7a Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sat, 7 Feb 2026 11:10:05 +0100 Subject: [PATCH 1/9] [refactor] Centralize entry classification in filesystem inspector --- .../Inventories/IFileSystemInspector.cs | 2 + .../Inventories/FileSystemInspector.cs | 72 +++++++++++ .../Services/Inventories/InventoryBuilder.cs | 53 +------- .../Inventories/FileSystemInspectorTests.cs | 122 ++++++++++++++++++ .../InventoryBuilderInspectorTests.cs | 68 +++++++--- 5 files changed, 249 insertions(+), 68 deletions(-) create mode 100644 tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs diff --git a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs index fe0a8db8..d841ffc9 100644 --- a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs +++ b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs @@ -1,10 +1,12 @@ using System.IO; +using ByteSync.Business.Inventories; using ByteSync.Common.Business.Misc; namespace ByteSync.Interfaces.Controls.Inventories; public interface IFileSystemInspector { + FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi); bool IsHidden(FileSystemInfo fsi, OSPlatforms os); bool IsSystemAttribute(FileInfo fileInfo); bool IsNoiseFileName(FileInfo fileInfo, OSPlatforms os); diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs index ac3675fa..1d69dcd1 100644 --- a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -1,4 +1,5 @@ using System.IO; +using ByteSync.Business.Inventories; using ByteSync.Common.Business.Misc; using ByteSync.Interfaces.Controls.Inventories; @@ -7,6 +8,48 @@ namespace ByteSync.Services.Inventories; public class FileSystemInspector : IFileSystemInspector { private const int FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 4194304; + private readonly IPosixFileTypeClassifier _posixFileTypeClassifier; + + public FileSystemInspector(IPosixFileTypeClassifier? posixFileTypeClassifier = null) + { + _posixFileTypeClassifier = posixFileTypeClassifier ?? new PosixFileTypeClassifier(); + } + + public FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi) + { + if (fsi is null) + { + return FileSystemEntryKind.Unknown; + } + + if (HasLinkTarget(fsi) || SafeIsReparsePoint(fsi)) + { + return FileSystemEntryKind.Symlink; + } + + if (!OperatingSystem.IsWindows()) + { + try + { + var posixKind = _posixFileTypeClassifier.ClassifyPosixEntry(fsi.FullName); + if (posixKind != FileSystemEntryKind.Unknown) + { + return posixKind; + } + } + catch (Exception) + { + return FileSystemEntryKind.Unknown; + } + } + + return fsi switch + { + DirectoryInfo => FileSystemEntryKind.Directory, + FileInfo => FileSystemEntryKind.RegularFile, + _ => FileSystemEntryKind.Unknown + }; + } public bool IsHidden(FileSystemInfo fsi, OSPlatforms os) { @@ -53,4 +96,33 @@ public bool IsRecallOnDataAccess(FileInfo fileInfo) { return (((int)fileInfo.Attributes) & FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) == FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS; } + + private static bool HasLinkTarget(FileSystemInfo fsi) + { + try + { + return fsi switch + { + FileInfo fileInfo => fileInfo.LinkTarget != null, + DirectoryInfo directoryInfo => directoryInfo.LinkTarget != null, + _ => false + }; + } + catch (Exception) + { + return false; + } + } + + private bool SafeIsReparsePoint(FileSystemInfo fsi) + { + try + { + return IsReparsePoint(fsi); + } + catch (Exception) + { + return false; + } + } } diff --git a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index 83e04c88..3677bcc8 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -46,8 +46,7 @@ public InventoryBuilder(SessionMember sessionMember, DataNode dataNode, SessionS InventorySaver = inventorySaver; InventoryFileAnalyzer = inventoryFileAnalyzer; - FileSystemInspector = fileSystemInspector ?? new FileSystemInspector(); - PosixFileTypeClassifier = posixFileTypeClassifier ?? new PosixFileTypeClassifier(); + FileSystemInspector = fileSystemInspector ?? new FileSystemInspector(posixFileTypeClassifier); } private Inventory InstantiateInventory() @@ -90,8 +89,6 @@ private Inventory InstantiateInventory() private IFileSystemInspector FileSystemInspector { get; } - private IPosixFileTypeClassifier PosixFileTypeClassifier { get; } - private bool IgnoreHidden { get { return SessionSettings is { ExcludeHiddenFiles: true }; } @@ -268,12 +265,7 @@ private void ProcessSubDirectories(InventoryPart inventoryPart, DirectoryInfo di try { - if (IsReparsePoint(subDirectory)) - { - RecordSkippedEntry(inventoryPart, subDirectory, SkipReason.Symlink, FileSystemEntryKind.Symlink); - - continue; - } + DoAnalyze(inventoryPart, subDirectory, cancellationToken); } catch (UnauthorizedAccessException ex) { @@ -296,8 +288,6 @@ private void ProcessSubDirectories(InventoryPart inventoryPart, DirectoryInfo di continue; } - - DoAnalyze(inventoryPart, subDirectory, cancellationToken); } } @@ -408,7 +398,7 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) private bool TryHandleFileSkip(InventoryPart inventoryPart, FileInfo fileInfo, bool isRoot) { - var entryKind = PosixFileTypeClassifier.ClassifyPosixEntry(fileInfo.FullName); + var entryKind = FileSystemInspector.ClassifyEntry(fileInfo); if (entryKind == FileSystemEntryKind.Symlink) { RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.Symlink, FileSystemEntryKind.Symlink); @@ -441,13 +431,6 @@ private bool TryHandleFileSkip(InventoryPart inventoryPart, FileInfo fileInfo, b } } - if (IsReparsePoint(fileInfo)) - { - RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.Symlink, FileSystemEntryKind.Symlink); - - return true; - } - if (!FileSystemInspector.Exists(fileInfo)) { RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.NotFound); @@ -480,7 +463,7 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, return; } - var entryKind = PosixFileTypeClassifier.ClassifyPosixEntry(directoryInfo.FullName); + var entryKind = FileSystemInspector.ClassifyEntry(directoryInfo); if (entryKind == FileSystemEntryKind.Symlink) { RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.Symlink, FileSystemEntryKind.Symlink); @@ -576,32 +559,6 @@ private bool ShouldIgnoreHiddenFile(FileInfo fileInfo) return null; } - private bool IsReparsePoint(FileInfo fileInfo) - { - if (FileSystemInspector.IsReparsePoint(fileInfo)) - { - _logger.LogWarning( - "File {File} is ignored because it has flag 'ReparsePoint'. It might be a symbolic link", - fileInfo.FullName); - - return true; - } - - return false; - } - - private bool IsReparsePoint(DirectoryInfo directoryInfo) - { - if (FileSystemInspector.IsReparsePoint(directoryInfo)) - { - _logger.LogWarning("Directory {Directory} is ignored because it has flag 'ReparsePoint'", directoryInfo.FullName); - - return true; - } - - return false; - } - private bool IsRecallOnDataAccess(FileInfo fileInfo) { return FileSystemInspector.IsRecallOnDataAccess(fileInfo); @@ -748,4 +705,4 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes } } } -} \ No newline at end of file +} diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs new file mode 100644 index 00000000..698e1b5c --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs @@ -0,0 +1,122 @@ +using ByteSync.Business.Inventories; +using ByteSync.Interfaces.Controls.Inventories; +using ByteSync.Services.Inventories; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Inventories; + +public class FileSystemInspectorTests +{ + [Test] + public void ClassifyEntry_ReturnsDirectory_ForDirectoryInfo() + { + var posix = new Mock(MockBehavior.Strict); + posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Unknown); + var inspector = new FileSystemInspector(posix.Object); + var tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + + try + { + var result = inspector.ClassifyEntry(tempDirectory); + + result.Should().Be(FileSystemEntryKind.Directory); + } + finally + { + Directory.Delete(tempDirectory.FullName, true); + } + } + + [Test] + public void ClassifyEntry_ReturnsRegularFile_ForFileInfo() + { + var posix = new Mock(MockBehavior.Strict); + posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Unknown); + var inspector = new FileSystemInspector(posix.Object); + var tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var tempFilePath = Path.Combine(tempDirectory.FullName, "file.txt"); + File.WriteAllText(tempFilePath, "x"); + var fileInfo = new FileInfo(tempFilePath); + + try + { + var result = inspector.ClassifyEntry(fileInfo); + + result.Should().Be(FileSystemEntryKind.RegularFile); + } + finally + { + Directory.Delete(tempDirectory.FullName, true); + } + } + + [Test] + public void ClassifyEntry_ReturnsSymlink_WhenLinkTargetExists() + { + var posix = new Mock(MockBehavior.Strict); + posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Unknown); + var inspector = new FileSystemInspector(posix.Object); + var tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var targetPath = Path.Combine(tempDirectory.FullName, "target.txt"); + File.WriteAllText(targetPath, "x"); + var linkPath = Path.Combine(tempDirectory.FullName, "link.txt"); + + try + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + } + catch (Exception ex) + { + Assert.Ignore($"Symbolic link creation failed: {ex.GetType().Name}"); + } + + var result = inspector.ClassifyEntry(new FileInfo(linkPath)); + + result.Should().Be(FileSystemEntryKind.Symlink); + } + finally + { + Directory.Delete(tempDirectory.FullName, true); + } + } + + [Test] + [Platform(Include = "Linux,MacOsX")] + public void ClassifyEntry_ReturnsPosixSpecialKind_WhenClassifierProvidesOne() + { + var posix = new Mock(MockBehavior.Strict); + posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Fifo); + var inspector = new FileSystemInspector(posix.Object); + var tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var tempFilePath = Path.Combine(tempDirectory.FullName, "file.txt"); + File.WriteAllText(tempFilePath, "x"); + var fileInfo = new FileInfo(tempFilePath); + + try + { + var result = inspector.ClassifyEntry(fileInfo); + + result.Should().Be(FileSystemEntryKind.Fifo); + } + finally + { + Directory.Delete(tempDirectory.FullName, true); + } + } + + [Test] + public void ClassifyEntry_ReturnsUnknown_ForNullEntry() + { + var posix = new Mock(MockBehavior.Strict); + posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Unknown); + var inspector = new FileSystemInspector(posix.Object); + + var result = inspector.ClassifyEntry(null!); + + result.Should().Be(FileSystemEntryKind.Unknown); + } +} diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs index d6d7e941..888b1a7f 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs @@ -83,11 +83,24 @@ private InventoryBuilder CreateBuilder(IFileSystemInspector inspector, Inventory var builder = CreateBuilder(inspector, processData, posixFileTypeClassifier); return (builder, processData); } + + private static void SetupDefaultClassification(Mock inspector) + { + inspector + .Setup(i => i.ClassifyEntry(It.IsAny())) + .Returns(fsi => fsi switch + { + DirectoryInfo => FileSystemEntryKind.Directory, + FileInfo => FileSystemEntryKind.RegularFile, + _ => FileSystemEntryKind.Unknown + }); + } [Test] public async Task Hidden_Root_File_Is_Analyzed() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(true); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(false); @@ -112,6 +125,7 @@ public async Task Hidden_Root_File_Is_Analyzed() public async Task System_Root_File_Is_Analyzed() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(true); @@ -136,6 +150,7 @@ public async Task System_Root_File_Is_Analyzed() public async Task Hidden_Root_Directory_Is_Analyzed() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(true); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); @@ -163,6 +178,7 @@ public async Task Hidden_Root_Directory_Is_Analyzed() public async Task Hidden_Child_File_Is_Ignored() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsHidden(It.Is(fi => fi.Name == "hidden.txt"), It.IsAny())) .Returns(true); @@ -194,6 +210,7 @@ public async Task Hidden_Child_File_Is_Ignored() public async Task System_Child_File_Is_Ignored() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); @@ -223,6 +240,7 @@ public async Task System_Child_File_Is_Ignored() public async Task Noise_Child_File_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.Is(fi => fi.Name == "thumbs.db"), It.IsAny())) @@ -254,6 +272,7 @@ public async Task Noise_Child_File_Is_Recorded() public async Task System_Child_File_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); @@ -283,6 +302,7 @@ public async Task System_Child_File_Is_Recorded() public async Task Offline_Root_File_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsReparsePoint(It.IsAny())).Returns(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(true); @@ -306,9 +326,9 @@ public async Task Offline_Root_File_Is_Recorded() public async Task Posix_Symlink_File_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); - var posix = new Mock(MockBehavior.Strict); - posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Symlink); - var (builder, processData) = CreateBuilderWithData(insp.Object, posix.Object); + SetupDefaultClassification(insp); + insp.Setup(i => i.ClassifyEntry(It.IsAny())).Returns(FileSystemEntryKind.Symlink); + var (builder, processData) = CreateBuilderWithData(insp.Object); var filePath = Path.Combine(TestDirectory.FullName, "posix_symlink.txt"); await File.WriteAllTextAsync(filePath, "x"); @@ -327,9 +347,9 @@ public async Task Posix_Symlink_File_Is_Recorded() public async Task Posix_Special_File_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); - var posix = new Mock(MockBehavior.Strict); - posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.BlockDevice); - var (builder, processData) = CreateBuilderWithData(insp.Object, posix.Object); + SetupDefaultClassification(insp); + insp.Setup(i => i.ClassifyEntry(It.IsAny())).Returns(FileSystemEntryKind.BlockDevice); + var (builder, processData) = CreateBuilderWithData(insp.Object); var filePath = Path.Combine(TestDirectory.FullName, "posix_special.txt"); await File.WriteAllTextAsync(filePath, "x"); @@ -349,9 +369,9 @@ public async Task Posix_Special_File_Is_Recorded() public async Task Posix_Symlink_Directory_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); - var posix = new Mock(MockBehavior.Strict); - posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Symlink); - var (builder, processData) = CreateBuilderWithData(insp.Object, posix.Object); + SetupDefaultClassification(insp); + insp.Setup(i => i.ClassifyEntry(It.IsAny())).Returns(FileSystemEntryKind.Symlink); + var (builder, processData) = CreateBuilderWithData(insp.Object); var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_posix_symlink")); @@ -369,9 +389,9 @@ public async Task Posix_Symlink_Directory_Is_Recorded() public async Task Posix_Special_Directory_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); - var posix = new Mock(MockBehavior.Strict); - posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.BlockDevice); - var (builder, processData) = CreateBuilderWithData(insp.Object, posix.Object); + SetupDefaultClassification(insp); + insp.Setup(i => i.ClassifyEntry(It.IsAny())).Returns(FileSystemEntryKind.BlockDevice); + var (builder, processData) = CreateBuilderWithData(insp.Object); var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_posix_special")); @@ -391,10 +411,11 @@ public async Task Posix_Special_Directory_Is_Recorded() public async Task Reparse_File_Is_Ignored() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); + insp.Setup(i => i.ClassifyEntry(It.IsAny())).Returns(FileSystemEntryKind.Symlink); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(false); - insp.Setup(i => i.IsReparsePoint(It.IsAny())).Returns(true); var builder = CreateBuilder(insp.Object); var filePath = Path.Combine(TestDirectory.FullName, "c.txt"); @@ -412,6 +433,7 @@ public async Task Reparse_File_Is_Ignored() public async Task ExistsFalse_File_Is_Ignored() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(false); @@ -436,6 +458,7 @@ public async Task ExistsFalse_File_Is_Ignored() public async Task UnauthorizedAccess_Adds_Inaccessible_FileDescription() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); // Directory is readable insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); @@ -470,6 +493,7 @@ public async Task UnauthorizedAccess_Adds_Inaccessible_FileDescription() public async Task DirectoryNotFound_Adds_Inaccessible_FileDescription() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())) .Throws(new DirectoryNotFoundException("parent missing")); @@ -501,6 +525,7 @@ public async Task DirectoryNotFound_Adds_Inaccessible_FileDescription() public async Task IOException_Adds_Inaccessible_FileDescription() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())) .Throws(new IOException("io error")); @@ -532,6 +557,7 @@ public async Task IOException_Adds_Inaccessible_FileDescription() public async Task Directory_IOException_Marked_Inaccessible_And_Skipped() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(false); @@ -544,14 +570,10 @@ public async Task Directory_IOException_Marked_Inaccessible_And_Skipped() var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_dir_io")); var sub = Directory.CreateDirectory(Path.Combine(root.FullName, "BadSub")); - // Throw IOException for this specific subdirectory when checking reparse - insp.Setup(i => i.IsReparsePoint(It.Is(fsi => fsi.FullName == sub.FullName))) + // Throw IOException for this specific subdirectory during classification + insp.Setup(i => i.ClassifyEntry(It.Is(fsi => fsi.FullName == sub.FullName))) .Throws(new IOException("dir io error")); - // Default to not reparse otherwise - insp.Setup(i => i.IsReparsePoint(It.Is(fsi => fsi.FullName != sub.FullName))) - .Returns(false); - var okFile = Path.Combine(root.FullName, "ok.txt"); await File.WriteAllTextAsync(okFile, "x"); @@ -572,6 +594,7 @@ public async Task Directory_IOException_Marked_Inaccessible_And_Skipped() public async Task Directory_ReparsePoint_Is_Skipped() { var insp = new Mock(MockBehavior.Strict); + SetupDefaultClassification(insp); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(false); @@ -585,7 +608,12 @@ public async Task Directory_ReparsePoint_Is_Skipped() // Define the desired value and then record the behavior based on that value. var reparseDir = sub.FullName; - insp.Setup(i => i.IsReparsePoint(It.IsAny())).Returns(fsi => fsi.FullName == reparseDir); + insp.Setup(i => i.ClassifyEntry(It.IsAny())) + .Returns(fsi => fsi.FullName == reparseDir + ? FileSystemEntryKind.Symlink + : fsi is DirectoryInfo + ? FileSystemEntryKind.Directory + : FileSystemEntryKind.RegularFile); var builder = CreateBuilder(insp.Object); From 0a96d99acd1a49255e2570d3de396736bc6f72b5 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Tue, 10 Feb 2026 14:04:30 +0100 Subject: [PATCH 2/9] refactor: cleanup --- .../Controls/Inventories/IFileSystemInspector.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs index d841ffc9..bd08696d 100644 --- a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs +++ b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs @@ -7,11 +7,18 @@ namespace ByteSync.Interfaces.Controls.Inventories; public interface IFileSystemInspector { FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi); + bool IsHidden(FileSystemInfo fsi, OSPlatforms os); + bool IsSystemAttribute(FileInfo fileInfo); + bool IsNoiseFileName(FileInfo fileInfo, OSPlatforms os); + bool IsReparsePoint(FileSystemInfo fsi); + bool Exists(FileInfo fileInfo); + bool IsOffline(FileInfo fileInfo); + bool IsRecallOnDataAccess(FileInfo fileInfo); -} +} \ No newline at end of file From 1c9be37b0fff3413f8a1420d0596c8ce3d8074b2 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Tue, 10 Feb 2026 14:05:02 +0100 Subject: [PATCH 3/9] refactor: cleanup --- .../Inventories/FileSystemInspector.cs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs index 1d69dcd1..75c4e354 100644 --- a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -9,24 +9,19 @@ public class FileSystemInspector : IFileSystemInspector { private const int FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 4194304; private readonly IPosixFileTypeClassifier _posixFileTypeClassifier; - + public FileSystemInspector(IPosixFileTypeClassifier? posixFileTypeClassifier = null) { _posixFileTypeClassifier = posixFileTypeClassifier ?? new PosixFileTypeClassifier(); } - + public FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi) { - if (fsi is null) - { - return FileSystemEntryKind.Unknown; - } - if (HasLinkTarget(fsi) || SafeIsReparsePoint(fsi)) { return FileSystemEntryKind.Symlink; } - + if (!OperatingSystem.IsWindows()) { try @@ -42,7 +37,7 @@ public FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi) return FileSystemEntryKind.Unknown; } } - + return fsi switch { DirectoryInfo => FileSystemEntryKind.Directory, @@ -96,7 +91,7 @@ public bool IsRecallOnDataAccess(FileInfo fileInfo) { return (((int)fileInfo.Attributes) & FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) == FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS; } - + private static bool HasLinkTarget(FileSystemInfo fsi) { try @@ -113,7 +108,7 @@ private static bool HasLinkTarget(FileSystemInfo fsi) return false; } } - + private bool SafeIsReparsePoint(FileSystemInfo fsi) { try @@ -125,4 +120,4 @@ private bool SafeIsReparsePoint(FileSystemInfo fsi) return false; } } -} +} \ No newline at end of file From 7c4c21f3210ba925ca47adc20a09417a3033a484 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Tue, 10 Feb 2026 14:06:31 +0100 Subject: [PATCH 4/9] refactor: cleanup --- src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index 3677bcc8..cd673e30 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -705,4 +705,4 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes } } } -} +} \ No newline at end of file From 5e3c699dc11e18bfcb69bd9bd597cbf367d6109e Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Tue, 10 Feb 2026 14:21:31 +0100 Subject: [PATCH 5/9] Fix POSIX classification fallback and add regression test --- .../Inventories/FileSystemInspector.cs | 3 +-- .../Inventories/FileSystemInspectorTests.cs | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs index 75c4e354..6451f19b 100644 --- a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -34,7 +34,6 @@ public FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi) } catch (Exception) { - return FileSystemEntryKind.Unknown; } } @@ -120,4 +119,4 @@ private bool SafeIsReparsePoint(FileSystemInfo fsi) return false; } } -} \ No newline at end of file +} diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs index 698e1b5c..41c3177c 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs @@ -108,6 +108,30 @@ public void ClassifyEntry_ReturnsPosixSpecialKind_WhenClassifierProvidesOne() } } + [Test] + [Platform(Include = "Linux,MacOsX")] + public void ClassifyEntry_FallsBackToRegularFile_WhenPosixClassifierThrows() + { + var posix = new Mock(MockBehavior.Strict); + posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Throws(new InvalidOperationException("boom")); + var inspector = new FileSystemInspector(posix.Object); + var tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var tempFilePath = Path.Combine(tempDirectory.FullName, "file.txt"); + File.WriteAllText(tempFilePath, "x"); + var fileInfo = new FileInfo(tempFilePath); + + try + { + var result = inspector.ClassifyEntry(fileInfo); + + result.Should().Be(FileSystemEntryKind.RegularFile); + } + finally + { + Directory.Delete(tempDirectory.FullName, true); + } + } + [Test] public void ClassifyEntry_ReturnsUnknown_ForNullEntry() { From a9c9ca815250b86166ceaae331e0c5edec4dcaae Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Tue, 10 Feb 2026 14:24:36 +0100 Subject: [PATCH 6/9] refactor: disable once EmptyGeneralCatchClause --- .../Services/Inventories/FileSystemInspector.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs index 6451f19b..3072a48a 100644 --- a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -32,6 +32,8 @@ public FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi) return posixKind; } } + + // ReSharper disable once EmptyGeneralCatchClause catch (Exception) { } @@ -119,4 +121,4 @@ private bool SafeIsReparsePoint(FileSystemInfo fsi) return false; } } -} +} \ No newline at end of file From 91f0ced7614e7b86dc7d2fe9ef57000d6601ee1d Mon Sep 17 00:00:00 2001 From: Paul Fresquet <61119222+paul-fresquet@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:48:27 +0100 Subject: [PATCH 7/9] Update tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Services/Inventories/InventoryBuilderInspectorTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs index 888b1a7f..3ac31b4c 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs @@ -303,7 +303,6 @@ public async Task Offline_Root_File_Is_Recorded() { var insp = new Mock(MockBehavior.Strict); SetupDefaultClassification(insp); - insp.Setup(i => i.IsReparsePoint(It.IsAny())).Returns(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(true); insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); From a5f110c344343df3cae3859a969dd9da4946a937 Mon Sep 17 00:00:00 2001 From: Paul Fresquet <61119222+paul-fresquet@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:48:39 +0100 Subject: [PATCH 8/9] Update src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Services/Inventories/FileSystemInspector.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs index 3072a48a..213add22 100644 --- a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -32,10 +32,9 @@ public FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi) return posixKind; } } - - // ReSharper disable once EmptyGeneralCatchClause - catch (Exception) + catch (Exception ex) { + System.Diagnostics.Debug.WriteLine($"Failed to classify POSIX entry '{fsi.FullName}': {ex}"); } } From 9167faea048218eabaa8c09abfd0fdf80e534f9c Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Tue, 10 Feb 2026 16:55:33 +0100 Subject: [PATCH 9/9] test: remove obsolete test --- .../Inventories/FileSystemInspectorTests.cs | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs index 41c3177c..618a7fcc 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/FileSystemInspectorTests.cs @@ -16,11 +16,11 @@ public void ClassifyEntry_ReturnsDirectory_ForDirectoryInfo() posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Unknown); var inspector = new FileSystemInspector(posix.Object); var tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); - + try { var result = inspector.ClassifyEntry(tempDirectory); - + result.Should().Be(FileSystemEntryKind.Directory); } finally @@ -28,7 +28,7 @@ public void ClassifyEntry_ReturnsDirectory_ForDirectoryInfo() Directory.Delete(tempDirectory.FullName, true); } } - + [Test] public void ClassifyEntry_ReturnsRegularFile_ForFileInfo() { @@ -39,11 +39,11 @@ public void ClassifyEntry_ReturnsRegularFile_ForFileInfo() var tempFilePath = Path.Combine(tempDirectory.FullName, "file.txt"); File.WriteAllText(tempFilePath, "x"); var fileInfo = new FileInfo(tempFilePath); - + try { var result = inspector.ClassifyEntry(fileInfo); - + result.Should().Be(FileSystemEntryKind.RegularFile); } finally @@ -51,7 +51,7 @@ public void ClassifyEntry_ReturnsRegularFile_ForFileInfo() Directory.Delete(tempDirectory.FullName, true); } } - + [Test] public void ClassifyEntry_ReturnsSymlink_WhenLinkTargetExists() { @@ -62,7 +62,7 @@ public void ClassifyEntry_ReturnsSymlink_WhenLinkTargetExists() var targetPath = Path.Combine(tempDirectory.FullName, "target.txt"); File.WriteAllText(targetPath, "x"); var linkPath = Path.Combine(tempDirectory.FullName, "link.txt"); - + try { try @@ -73,9 +73,9 @@ public void ClassifyEntry_ReturnsSymlink_WhenLinkTargetExists() { Assert.Ignore($"Symbolic link creation failed: {ex.GetType().Name}"); } - + var result = inspector.ClassifyEntry(new FileInfo(linkPath)); - + result.Should().Be(FileSystemEntryKind.Symlink); } finally @@ -83,7 +83,7 @@ public void ClassifyEntry_ReturnsSymlink_WhenLinkTargetExists() Directory.Delete(tempDirectory.FullName, true); } } - + [Test] [Platform(Include = "Linux,MacOsX")] public void ClassifyEntry_ReturnsPosixSpecialKind_WhenClassifierProvidesOne() @@ -95,11 +95,11 @@ public void ClassifyEntry_ReturnsPosixSpecialKind_WhenClassifierProvidesOne() var tempFilePath = Path.Combine(tempDirectory.FullName, "file.txt"); File.WriteAllText(tempFilePath, "x"); var fileInfo = new FileInfo(tempFilePath); - + try { var result = inspector.ClassifyEntry(fileInfo); - + result.Should().Be(FileSystemEntryKind.Fifo); } finally @@ -107,7 +107,7 @@ public void ClassifyEntry_ReturnsPosixSpecialKind_WhenClassifierProvidesOne() Directory.Delete(tempDirectory.FullName, true); } } - + [Test] [Platform(Include = "Linux,MacOsX")] public void ClassifyEntry_FallsBackToRegularFile_WhenPosixClassifierThrows() @@ -119,11 +119,11 @@ public void ClassifyEntry_FallsBackToRegularFile_WhenPosixClassifierThrows() var tempFilePath = Path.Combine(tempDirectory.FullName, "file.txt"); File.WriteAllText(tempFilePath, "x"); var fileInfo = new FileInfo(tempFilePath); - + try { var result = inspector.ClassifyEntry(fileInfo); - + result.Should().Be(FileSystemEntryKind.RegularFile); } finally @@ -131,16 +131,4 @@ public void ClassifyEntry_FallsBackToRegularFile_WhenPosixClassifierThrows() Directory.Delete(tempDirectory.FullName, true); } } - - [Test] - public void ClassifyEntry_ReturnsUnknown_ForNullEntry() - { - var posix = new Mock(MockBehavior.Strict); - posix.Setup(p => p.ClassifyPosixEntry(It.IsAny())).Returns(FileSystemEntryKind.Unknown); - var inspector = new FileSystemInspector(posix.Object); - - var result = inspector.ClassifyEntry(null!); - - result.Should().Be(FileSystemEntryKind.Unknown); - } -} +} \ No newline at end of file