Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClassifyEntry defensively handles null inputs, but the interface contract uses a non-nullable FileSystemInfo parameter. With nullable enabled, consider changing the signature to FileSystemInfo? to reflect the supported input and avoid callers needing null! in tests.

Suggested change
FileSystemEntryKind ClassifyEntry(FileSystemInfo fsi);
FileSystemEntryKind ClassifyEntry(FileSystemInfo? fsi);

Copilot uses AI. Check for mistakes.
bool IsHidden(FileSystemInfo fsi, OSPlatforms os);
bool IsSystemAttribute(FileInfo fileInfo);
bool IsNoiseFileName(FileInfo fileInfo, OSPlatforms os);
Expand Down
72 changes: 72 additions & 0 deletions src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO;
using ByteSync.Business.Inventories;
using ByteSync.Common.Business.Misc;
using ByteSync.Interfaces.Controls.Inventories;

Expand All @@ -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;
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ClassifyEntry, the POSIX classification catch returns Unknown immediately. That short-circuits the intended fallback to Directory/RegularFile classification and can change behavior if the POSIX classifier throws. Consider swallowing/logging the exception and continuing to the type-based fallback instead of returning early.

Suggested change
return FileSystemEntryKind.Unknown;
// Ignore POSIX classification failures and fall back to type-based classification below.

Copilot uses AI. Check for mistakes.
}
}

return fsi switch
{
DirectoryInfo => FileSystemEntryKind.Directory,
FileInfo => FileSystemEntryKind.RegularFile,
_ => FileSystemEntryKind.Unknown
};
}

public bool IsHidden(FileSystemInfo fsi, OSPlatforms os)
{
Expand Down Expand Up @@ -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;
}
}
}
53 changes: 5 additions & 48 deletions src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -90,8 +89,6 @@ private Inventory InstantiateInventory()

private IFileSystemInspector FileSystemInspector { get; }

private IPosixFileTypeClassifier PosixFileTypeClassifier { get; }

private bool IgnoreHidden
{
get { return SessionSettings is { ExcludeHiddenFiles: true }; }
Expand Down Expand Up @@ -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)
{
Expand All @@ -296,8 +288,6 @@ private void ProcessSubDirectories(InventoryPart inventoryPart, DirectoryInfo di

continue;
}

DoAnalyze(inventoryPart, subDirectory, cancellationToken);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -748,4 +705,4 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<IPosixFileTypeClassifier>(MockBehavior.Strict);
posix.Setup(p => p.ClassifyPosixEntry(It.IsAny<string>())).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<IPosixFileTypeClassifier>(MockBehavior.Strict);
posix.Setup(p => p.ClassifyPosixEntry(It.IsAny<string>())).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<IPosixFileTypeClassifier>(MockBehavior.Strict);
posix.Setup(p => p.ClassifyPosixEntry(It.IsAny<string>())).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<IPosixFileTypeClassifier>(MockBehavior.Strict);
posix.Setup(p => p.ClassifyPosixEntry(It.IsAny<string>())).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<IPosixFileTypeClassifier>(MockBehavior.Strict);
posix.Setup(p => p.ClassifyPosixEntry(It.IsAny<string>())).Returns(FileSystemEntryKind.Unknown);
var inspector = new FileSystemInspector(posix.Object);

var result = inspector.ClassifyEntry(null!);

result.Should().Be(FileSystemEntryKind.Unknown);
}
}
Loading
Loading