diff --git a/skills/create-pr/SKILL.md b/skills/create-pr/SKILL.md new file mode 100644 index 00000000..6deda94b --- /dev/null +++ b/skills/create-pr/SKILL.md @@ -0,0 +1,49 @@ +--- +name: create-pr +description: Push the current branch and create a Pull Request for ByteSync. Use when the user wants the agent to push a branch and open a PR, and requires the PR title and description in English. +--- + +# Request PR + +## Overview + +Provide a short workflow to push the current branch and open a Pull Request with an English title and description using the `gh` CLI. + +## Workflow + +### 1) Confirm branch state + +Confirm the current branch name and whether there are uncommitted changes. If changes remain, ask whether to commit before pushing. + +### 2) Push branch + +Push the current branch to the remote. If the user did not specify a tool, default to using the `gh` CLI context and run a normal git push for the current branch. + +Example: +```bash +git push -u origin HEAD +``` + +### 3) Create PR + +Create a Pull Request using `gh pr create` with: + +- English title +- English description summarizing changes and approach + +### 4) Provide PR text template + +Provide a minimal PR template to pass to `gh pr create`: + +Title: `[type] Short summary` + +Description: +- Summary: +- Key changes: +- Notes/risks: + +Ensure the template is in English and matches the repo's PR format when applicable. + +### 5) Keep it brief + +Keep the response focused on the push + PR actions, avoiding extra steps unless the user asks. diff --git a/skills/dist/create-pr.skill b/skills/dist/create-pr.skill new file mode 100644 index 00000000..4413b2a8 Binary files /dev/null and b/skills/dist/create-pr.skill differ diff --git a/skills/dist/perform-implement-plan.skill b/skills/dist/perform-implement-plan.skill new file mode 100644 index 00000000..e5b55117 Binary files /dev/null and b/skills/dist/perform-implement-plan.skill differ diff --git a/skills/perform-implement-plan/SKILL.md b/skills/perform-implement-plan/SKILL.md new file mode 100644 index 00000000..5881b437 --- /dev/null +++ b/skills/perform-implement-plan/SKILL.md @@ -0,0 +1,47 @@ +--- +name: perform-implement-plan +description: Execute an implementation plan end-to-end in the ByteSync repo. Use when the user asks to implement a previously established plan step by step, requires creating a branch before coding, expects regular commits during progress, and wants build/test validation. +--- + +# Plan Implementation + +## Overview + +Implement a previously agreed plan in ByteSync, one step at a time, while enforcing branch creation and frequent commits. + +## Workflow + +### 1) Confirm plan scope + +Restate the implementation plan in brief. If the plan is not present in the conversation, ask for it before starting. + +### 2) Create a branch before implementation + +Create a new branch before making any code changes. The branch name must follow ByteSync guidelines: + +- Use one prefix only: `feature/`, `fix/`, `refactor/`, `docs/`, or `test/` +- Avoid extra slashes beyond the prefix +- Include the issue number if an issue exists (e.g., `feature/1234-add-sync-filter`) +- Add a short English description + +### 3) Implement step by step + +Implement the plan in small, ordered steps. After each step: + +- Verify the change fits the plan +- Commit the work with a concise English message +- Keep commits focused and incremental + +### 4) Respect repo conventions and validation + +Follow the repo guidance (AGENTS.md), including: + +- Do not add unnecessary comments +- Use FluentAssertions for tests +- Build and test as separate commands +- During intermediate commits, run only tests related to the changes when reasonable +- At the end, run the full solution tests + +### 5) Keep the user informed + +Provide short progress updates after each step and call out any blockers or missing details before proceeding. diff --git a/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs b/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs index ec7735a0..44102652 100644 --- a/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs +++ b/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs @@ -1,4 +1,5 @@ -using System.Reactive.Linq; +using System.Collections.Concurrent; +using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading; using ByteSync.Interfaces.Controls.Inventories; @@ -11,6 +12,7 @@ namespace ByteSync.Business.Inventories; public class InventoryProcessData : ReactiveObject { private readonly object _monitorDataLock = new object(); + private readonly ConcurrentQueue _skippedEntries = new(); public InventoryProcessData() { @@ -101,6 +103,8 @@ public List? Inventories public IObservable InventoryMonitorObservable => InventoryMonitorDataSubject.AsObservable(); + public IReadOnlyCollection SkippedEntries => _skippedEntries.ToArray(); + [Reactive] public DateTimeOffset InventoryStart { get; set; } @@ -131,8 +135,14 @@ public void Reset() LastException = null; InventoryMonitorDataSubject.OnNext(new InventoryMonitorData()); + ClearSkippedEntries(); } + public void RecordSkippedEntry(SkippedEntry entry) + { + _skippedEntries.Enqueue(entry); + } + public void SetError(Exception exception) { LastException = exception; @@ -150,4 +160,12 @@ public void UpdateMonitorData(Action action) InventoryMonitorDataSubject.OnNext(newValue); } } -} \ No newline at end of file + + private void ClearSkippedEntries() + { + while (_skippedEntries.TryDequeue(out _)) + { + } + } +} + diff --git a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs index fb54148f..fe0a8db8 100644 --- a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs +++ b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs @@ -6,9 +6,10 @@ namespace ByteSync.Interfaces.Controls.Inventories; public interface IFileSystemInspector { bool IsHidden(FileSystemInfo fsi, OSPlatforms os); - bool IsSystem(FileInfo fileInfo); + 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 +} diff --git a/src/ByteSync.Client/Models/Inventories/SkipReason.cs b/src/ByteSync.Client/Models/Inventories/SkipReason.cs new file mode 100644 index 00000000..d065e5c9 --- /dev/null +++ b/src/ByteSync.Client/Models/Inventories/SkipReason.cs @@ -0,0 +1,15 @@ +namespace ByteSync.Models.Inventories; + +public enum SkipReason +{ + Unknown = 0, + Hidden = 1, + SystemAttribute = 2, + NoiseFile = 3, + Symlink = 4, + SpecialPosixFile = 5, + Offline = 6, + Inaccessible = 7, + NotFound = 8, + IoError = 9 +} diff --git a/src/ByteSync.Client/Models/Inventories/SkippedEntry.cs b/src/ByteSync.Client/Models/Inventories/SkippedEntry.cs new file mode 100644 index 00000000..16de13f4 --- /dev/null +++ b/src/ByteSync.Client/Models/Inventories/SkippedEntry.cs @@ -0,0 +1,18 @@ +using ByteSync.Business.Inventories; + +namespace ByteSync.Models.Inventories; + +public class SkippedEntry +{ + public string FullPath { get; init; } = string.Empty; + + public string RelativePath { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public SkipReason Reason { get; init; } + + public FileSystemEntryKind? DetectedKind { get; init; } + + public DateTime SkippedAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs index bd3281e4..ac3675fa 100644 --- a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -16,12 +16,22 @@ public bool IsHidden(FileSystemInfo fsi, OSPlatforms os) return isHidden || isDot; } - public bool IsSystem(FileInfo fileInfo) + public bool IsSystemAttribute(FileInfo fileInfo) { - var isCommon = fileInfo.Name.In("desktop.ini", "thumbs.db", ".desktop.ini", ".thumbs.db", ".DS_Store"); var isSystem = (fileInfo.Attributes & FileAttributes.System) == FileAttributes.System; - return isCommon || isSystem; + return isSystem; + } + + public bool IsNoiseFileName(FileInfo fileInfo, OSPlatforms os) + { + var comparison = os == OSPlatforms.Linux ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + return fileInfo.Name.Equals("desktop.ini", comparison) + || fileInfo.Name.Equals("thumbs.db", comparison) + || fileInfo.Name.Equals(".desktop.ini", comparison) + || fileInfo.Name.Equals(".thumbs.db", comparison) + || fileInfo.Name.Equals(".DS_Store", comparison); } public bool IsReparsePoint(FileSystemInfo fsi) @@ -43,4 +53,4 @@ public bool IsRecallOnDataAccess(FileInfo fileInfo) { return (((int)fileInfo.Attributes) & FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) == FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS; } -} \ No newline at end of file +} diff --git a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index 46e3511d..948fbe4f 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -270,26 +270,27 @@ private void ProcessSubDirectories(InventoryPart inventoryPart, DirectoryInfo di { if (IsReparsePoint(subDirectory)) { + RecordSkippedEntry(inventoryPart, subDirectory, SkipReason.Symlink, FileSystemEntryKind.Symlink); continue; } } catch (UnauthorizedAccessException ex) { - AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, ex, + AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, SkipReason.Inaccessible, ex, "Directory {Directory} is inaccessible and will be skipped"); continue; } catch (DirectoryNotFoundException ex) { - AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, ex, + AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, SkipReason.NotFound, ex, "Directory {Directory} not found during enumeration and will be skipped"); continue; } catch (IOException ex) { - AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, ex, + AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, SkipReason.IoError, ex, "Directory {Directory} IO error and will be skipped"); continue; @@ -312,12 +313,14 @@ private void ProcessFiles(InventoryPart inventoryPart, DirectoryInfo directoryIn } } - private void AddInaccessibleDirectoryAndLog(InventoryPart inventoryPart, DirectoryInfo directoryInfo, Exception ex, string message) + private void AddInaccessibleDirectoryAndLog(InventoryPart inventoryPart, DirectoryInfo directoryInfo, SkipReason reason, + Exception ex, string message) { inventoryPart.IsIncompleteDueToAccess = true; var subDirectoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, directoryInfo); subDirectoryDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, subDirectoryDescription); + RecordSkippedEntry(inventoryPart, directoryInfo, reason); _logger.LogWarning(ex, message, directoryInfo.FullName); } @@ -375,6 +378,13 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) var isRoot = IsRootPath(inventoryPart, fileInfo); var entryKind = PosixFileTypeClassifier.ClassifyPosixEntry(fileInfo.FullName); + if (entryKind == FileSystemEntryKind.Symlink) + { + RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.Symlink, FileSystemEntryKind.Symlink); + + return; + } + if (IsPosixSpecialFile(entryKind)) { AddPosixSpecialFileAndLog(inventoryPart, fileInfo, entryKind); @@ -384,21 +394,40 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) if (!isRoot && ShouldIgnoreHiddenFile(fileInfo)) { + RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.Hidden, FileSystemEntryKind.RegularFile); + return; } - if (!isRoot && ShouldIgnoreSystemFile(fileInfo)) + if (!isRoot) { - return; + var systemSkipReason = GetSystemSkipReason(fileInfo); + if (systemSkipReason.HasValue) + { + RecordSkippedEntry(inventoryPart, fileInfo, systemSkipReason.Value, FileSystemEntryKind.RegularFile); + + return; + } } if (IsReparsePoint(fileInfo)) { + RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.Symlink, FileSystemEntryKind.Symlink); + return; } - if (!FileSystemInspector.Exists(fileInfo) || FileSystemInspector.IsOffline(fileInfo) || IsRecallOnDataAccess(fileInfo)) + if (!FileSystemInspector.Exists(fileInfo)) { + RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.NotFound); + + return; + } + + if (FileSystemInspector.IsOffline(fileInfo) || IsRecallOnDataAccess(fileInfo)) + { + RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.Offline, FileSystemEntryKind.RegularFile); + return; } @@ -410,17 +439,17 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) } catch (UnauthorizedAccessException ex) { - AddInaccessibleFileAndLog(inventoryPart, fileInfo, ex, + AddInaccessibleFileAndLog(inventoryPart, fileInfo, SkipReason.Inaccessible, ex, "File {File} is inaccessible and will be skipped"); } catch (DirectoryNotFoundException ex) { - AddInaccessibleFileAndLog(inventoryPart, fileInfo, ex, + AddInaccessibleFileAndLog(inventoryPart, fileInfo, SkipReason.NotFound, ex, "File {File} parent directory not found and will be skipped"); } catch (IOException ex) { - AddInaccessibleFileAndLog(inventoryPart, fileInfo, ex, + AddInaccessibleFileAndLog(inventoryPart, fileInfo, SkipReason.IoError, ex, "File {File} IO error and will be skipped"); } } @@ -440,8 +469,25 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, return; } + var entryKind = PosixFileTypeClassifier.ClassifyPosixEntry(directoryInfo.FullName); + if (entryKind == FileSystemEntryKind.Symlink) + { + RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.Symlink, FileSystemEntryKind.Symlink); + + return; + } + + if (IsPosixSpecialFile(entryKind)) + { + RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.SpecialPosixFile, entryKind); + + return; + } + if (!IsRootPath(inventoryPart, directoryInfo) && ShouldIgnoreHiddenDirectory(directoryInfo)) { + RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.Hidden, FileSystemEntryKind.Directory); + return; } @@ -459,16 +505,19 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, catch (UnauthorizedAccessException ex) { directoryDescription.IsAccessible = false; + RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.Inaccessible); _logger.LogWarning(ex, "Directory {Directory} is inaccessible and will be skipped", directoryInfo.FullName); } catch (DirectoryNotFoundException ex) { directoryDescription.IsAccessible = false; + RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.NotFound); _logger.LogWarning(ex, "Directory {Directory} not found during enumeration and will be skipped", directoryInfo.FullName); } catch (IOException ex) { directoryDescription.IsAccessible = false; + RecordSkippedEntry(inventoryPart, directoryInfo, SkipReason.IoError); _logger.LogWarning(ex, "Directory {Directory} IO error and will be skipped", directoryInfo.FullName); } } @@ -490,21 +539,28 @@ private bool ShouldIgnoreHiddenFile(FileInfo fileInfo) return false; } - private bool ShouldIgnoreSystemFile(FileInfo fileInfo) + private SkipReason? GetSystemSkipReason(FileInfo fileInfo) { if (!IgnoreSystem) { - return false; + return null; + } + + if (FileSystemInspector.IsNoiseFileName(fileInfo, OSPlatform)) + { + _logger.LogInformation("File {File} is ignored because considered as noise", fileInfo.FullName); + + return SkipReason.NoiseFile; } - if (FileSystemInspector.IsSystem(fileInfo)) + if (FileSystemInspector.IsSystemAttribute(fileInfo)) { _logger.LogInformation("File {File} is ignored because considered as system", fileInfo.FullName); - return true; + return SkipReason.SystemAttribute; } - return false; + return null; } private bool IsReparsePoint(FileInfo fileInfo) @@ -538,7 +594,8 @@ private bool IsRecallOnDataAccess(FileInfo fileInfo) return FileSystemInspector.IsRecallOnDataAccess(fileInfo); } - private void AddInaccessibleFileAndLog(InventoryPart inventoryPart, FileInfo fileInfo, Exception ex, string message) + private void AddInaccessibleFileAndLog(InventoryPart inventoryPart, FileInfo fileInfo, SkipReason reason, + Exception ex, string message) { inventoryPart.IsIncompleteDueToAccess = true; var relativePath = BuildRelativePath(inventoryPart, fileInfo); @@ -547,6 +604,7 @@ private void AddInaccessibleFileAndLog(InventoryPart inventoryPart, FileInfo fil IsAccessible = false }; AddFileSystemDescription(inventoryPart, fileDescription); + RecordSkippedEntry(inventoryPart, fileInfo, reason); _logger.LogWarning(ex, message, fileInfo.FullName); } @@ -559,6 +617,7 @@ private void AddPosixSpecialFileAndLog(InventoryPart inventoryPart, FileInfo fil IsAccessible = false }; AddFileSystemDescription(inventoryPart, fileDescription); + RecordSkippedEntry(inventoryPart, fileInfo, SkipReason.SpecialPosixFile, entryKind); _logger.LogWarning("File {File} is a POSIX special file ({EntryKind}) and will be skipped", fileInfo.FullName, entryKind); } @@ -571,6 +630,16 @@ FileSystemEntryKind.Fifo or FileSystemEntryKind.Socket; } + private string BuildRelativePath(InventoryPart inventoryPart, FileSystemInfo fileSystemInfo) + { + return fileSystemInfo switch + { + FileInfo fileInfo => BuildRelativePath(inventoryPart, fileInfo), + DirectoryInfo directoryInfo => BuildRelativePath(inventoryPart, directoryInfo), + _ => string.Empty + }; + } + private string BuildRelativePath(InventoryPart inventoryPart, FileInfo fileInfo) { if (inventoryPart.InventoryPartType != FileSystemTypes.Directory) @@ -591,6 +660,51 @@ private string BuildRelativePath(InventoryPart inventoryPart, FileInfo fileInfo) return normalizedPath; } + private string BuildRelativePath(InventoryPart inventoryPart, DirectoryInfo directoryInfo) + { + if (inventoryPart.InventoryPartType != FileSystemTypes.Directory) + { + return "/" + directoryInfo.Name; + } + + var rawRelativePath = IOUtils.ExtractRelativePath(directoryInfo.FullName, inventoryPart.RootPath); + var normalizedPath = OSPlatform == OSPlatforms.Windows + ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) + : rawRelativePath; + + if (!normalizedPath.StartsWith(IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR)) + { + normalizedPath = IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR + normalizedPath; + } + + return normalizedPath; + } + + private void RecordSkippedEntry(InventoryPart inventoryPart, FileSystemInfo fileSystemInfo, SkipReason reason, + FileSystemEntryKind? detectedKind = null) + { + string relativePath; + try + { + relativePath = BuildRelativePath(inventoryPart, fileSystemInfo); + } + catch (Exception) + { + relativePath = string.Empty; + } + + var entry = new SkippedEntry + { + FullPath = fileSystemInfo.FullName, + RelativePath = relativePath, + Name = fileSystemInfo.Name, + Reason = reason, + DetectedKind = detectedKind + }; + + InventoryProcessData.RecordSkippedEntry(entry); + } + private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDescription fileSystemDescription) { if (fileSystemDescription.RelativePath.IsNotEmpty() @@ -628,4 +742,4 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes } } } -} \ No newline at end of file +} diff --git a/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs b/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs new file mode 100644 index 00000000..d0005a5b --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using ByteSync.Business.Inventories; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Business.Inventories; + +[TestFixture] +public class InventoryProcessDataTests +{ + [Test] + public void SetError_ShouldUpdateLastException_AndRaiseEvent() + { + // Arrange + var data = new InventoryProcessData(); + var values = new List(); + data.ErrorEvent.Subscribe(values.Add); + var exception = new InvalidOperationException("boom"); + + // Act + data.SetError(exception); + + // Assert + data.LastException.Should().Be(exception); + values.Should().Contain(true); + } +} diff --git a/tests/ByteSync.Client.UnitTests/Models/Inventories/SkippedEntryTests.cs b/tests/ByteSync.Client.UnitTests/Models/Inventories/SkippedEntryTests.cs new file mode 100644 index 00000000..75380073 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Models/Inventories/SkippedEntryTests.cs @@ -0,0 +1,56 @@ +using ByteSync.Business.Inventories; +using ByteSync.Models.Inventories; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Models.Inventories; + +[TestFixture] +public class SkippedEntryTests +{ + [Test] + public void Constructor_ShouldSetDefaults() + { + // Arrange + var before = DateTime.UtcNow; + + // Act + var entry = new SkippedEntry(); + var after = DateTime.UtcNow; + + // Assert + entry.FullPath.Should().BeEmpty(); + entry.RelativePath.Should().BeEmpty(); + entry.Name.Should().BeEmpty(); + entry.Reason.Should().Be(SkipReason.Unknown); + entry.DetectedKind.Should().BeNull(); + entry.SkippedAt.Should().BeOnOrAfter(before); + entry.SkippedAt.Should().BeOnOrBefore(after); + } + + [Test] + public void InitProperties_ShouldSetValues() + { + // Arrange + var skippedAt = new DateTime(2025, 12, 1, 10, 30, 0, DateTimeKind.Utc); + + // Act + var entry = new SkippedEntry + { + FullPath = "/data/test.txt", + RelativePath = "/test.txt", + Name = "test.txt", + Reason = SkipReason.Hidden, + DetectedKind = FileSystemEntryKind.RegularFile, + SkippedAt = skippedAt + }; + + // Assert + entry.FullPath.Should().Be("/data/test.txt"); + entry.RelativePath.Should().Be("/test.txt"); + entry.Name.Should().Be("test.txt"); + entry.Reason.Should().Be(SkipReason.Hidden); + entry.DetectedKind.Should().Be(FileSystemEntryKind.RegularFile); + entry.SkippedAt.Should().Be(skippedAt); + } +} diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs index bbf9aee2..9a2710b1 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs @@ -6,6 +6,7 @@ using ByteSync.Common.Business.EndPoints; using ByteSync.Common.Business.Misc; using ByteSync.Interfaces.Controls.Inventories; +using ByteSync.Models.Inventories; using ByteSync.Services.Inventories; using ByteSync.TestsCommon; using FluentAssertions; @@ -36,7 +37,8 @@ public void TearDown() _manualResetEvents.Clear(); } - private InventoryBuilder CreateBuilder(IFileSystemInspector inspector) + private InventoryBuilder CreateBuilder(IFileSystemInspector inspector, InventoryProcessData? processData = null, + IPosixFileTypeClassifier? posixFileTypeClassifier = null) { var endpoint = new ByteSyncEndpoint { @@ -59,7 +61,7 @@ private InventoryBuilder CreateBuilder(IFileSystemInspector inspector) var dataNode = new DataNode { Id = Guid.NewGuid().ToString("N"), ClientInstanceId = endpoint.ClientInstanceId, Code = "A", OrderIndex = 0 }; var settings = SessionSettings.BuildDefault(); - var processData = new InventoryProcessData(); + processData ??= new InventoryProcessData(); var logger = new Mock>().Object; var analyzer = new Mock(); @@ -71,7 +73,15 @@ private InventoryBuilder CreateBuilder(IFileSystemInspector inspector) var indexer = new Mock().Object; return new InventoryBuilder(sessionMember, dataNode, settings, processData, endpoint.OSPlatform, - FingerprintModes.Rsync, logger, analyzer.Object, saver, indexer, inspector); + FingerprintModes.Rsync, logger, analyzer.Object, saver, indexer, inspector, posixFileTypeClassifier); + } + + private (InventoryBuilder Builder, InventoryProcessData ProcessData) CreateBuilderWithData(IFileSystemInspector inspector, + IPosixFileTypeClassifier? posixFileTypeClassifier = null) + { + var processData = new InventoryProcessData(); + var builder = CreateBuilder(inspector, processData, posixFileTypeClassifier); + return (builder, processData); } [Test] @@ -79,7 +89,8 @@ public async Task Hidden_Root_File_Is_Analyzed() { var insp = new Mock(MockBehavior.Strict); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(true); - insp.Setup(i => i.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -102,7 +113,8 @@ public async Task System_Root_File_Is_Analyzed() { var insp = new Mock(MockBehavior.Strict); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); - insp.Setup(i => i.IsSystem(It.IsAny())).Returns(true); + insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); + insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(true); 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(false); @@ -126,7 +138,8 @@ public async Task Hidden_Root_Directory_Is_Analyzed() var insp = new Mock(MockBehavior.Strict); 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.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -155,7 +168,8 @@ public async Task Hidden_Child_File_Is_Ignored() .Returns(true); insp.Setup(i => i.IsHidden(It.Is(fi => fi.Name != "hidden.txt"), It.IsAny())) .Returns(false); - insp.Setup(i => i.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -182,8 +196,9 @@ public async Task System_Child_File_Is_Ignored() var insp = new Mock(MockBehavior.Strict); 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.IsSystem(It.Is(fi => fi.Name == "system.txt"))).Returns(true); - insp.Setup(i => i.IsSystem(It.Is(fi => fi.Name != "system.txt"))).Returns(false); + insp.Setup(i => i.IsNoiseFileName(It.IsAny(), It.IsAny())).Returns(false); + insp.Setup(i => i.IsSystemAttribute(It.Is(fi => fi.Name == "system.txt"))).Returns(true); + insp.Setup(i => i.IsSystemAttribute(It.Is(fi => fi.Name != "system.txt"))).Returns(false); 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(false); @@ -203,13 +218,159 @@ public async Task System_Child_File_Is_Ignored() var part = builder.Inventory.InventoryParts.Single(); part.FileDescriptions.Should().ContainSingle(fd => fd.Name == "visible.txt"); } + + [Test] + public async Task Noise_Child_File_Is_Recorded() + { + var insp = new Mock(MockBehavior.Strict); + 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())) + .Returns(true); + insp.Setup(i => i.IsNoiseFileName(It.Is(fi => fi.Name != "thumbs.db"), It.IsAny())) + .Returns(false); + insp.Setup(i => i.IsSystemAttribute(It.IsAny())).Returns(false); + 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(false); + insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); + var (builder, processData) = CreateBuilderWithData(insp.Object); + + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_noise_child")); + var visiblePath = Path.Combine(root.FullName, "visible.txt"); + var noisePath = Path.Combine(root.FullName, "thumbs.db"); + await File.WriteAllTextAsync(visiblePath, "x"); + await File.WriteAllTextAsync(noisePath, "x"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv_noise_child.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + processData.SkippedEntries.Should() + .ContainSingle(e => e.Name == "thumbs.db" && e.Reason == SkipReason.NoiseFile); + } + + [Test] + public async Task System_Child_File_Is_Recorded() + { + var insp = new Mock(MockBehavior.Strict); + 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); + insp.Setup(i => i.IsSystemAttribute(It.Is(fi => fi.Name == "system.txt"))).Returns(true); + insp.Setup(i => i.IsSystemAttribute(It.Is(fi => fi.Name != "system.txt"))).Returns(false); + 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(false); + insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); + var (builder, processData) = CreateBuilderWithData(insp.Object); + + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_system_recorded")); + var visiblePath = Path.Combine(root.FullName, "visible.txt"); + var systemPath = Path.Combine(root.FullName, "system.txt"); + await File.WriteAllTextAsync(visiblePath, "x"); + await File.WriteAllTextAsync(systemPath, "x"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv_system_recorded.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + processData.SkippedEntries.Should() + .ContainSingle(e => e.Name == "system.txt" && e.Reason == SkipReason.SystemAttribute); + } + + [Test] + public async Task Offline_Root_File_Is_Recorded() + { + var insp = new Mock(MockBehavior.Strict); + 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); + var (builder, processData) = CreateBuilderWithData(insp.Object); + + var filePath = Path.Combine(TestDirectory.FullName, "offline.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv_offline.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().BeEmpty(); + processData.SkippedEntries.Should() + .ContainSingle(e => e.Name == "offline.txt" && e.Reason == SkipReason.Offline); + } + + [Test] + 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); + + var filePath = Path.Combine(TestDirectory.FullName, "posix_symlink.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv_posix_symlink.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().BeEmpty(); + processData.SkippedEntries.Should() + .ContainSingle(e => e.Name == "posix_symlink.txt" && e.Reason == SkipReason.Symlink); + } + + [Test] + 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); + + var filePath = Path.Combine(TestDirectory.FullName, "posix_special.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv_posix_special.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().ContainSingle(fd => !fd.IsAccessible); + processData.SkippedEntries.Should() + .ContainSingle(e => e.Name == "posix_special.txt" && e.Reason == SkipReason.SpecialPosixFile); + } + + [Test] + 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); + + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_posix_symlink")); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv_posix_symlink_dir.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.DirectoryDescriptions.Should().BeEmpty(); + processData.SkippedEntries.Should() + .ContainSingle(e => e.Name == "root_posix_symlink" && e.Reason == SkipReason.Symlink); + } [Test] public async Task Reparse_File_Is_Ignored() { var insp = new Mock(MockBehavior.Strict); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); - insp.Setup(i => i.IsSystem(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); @@ -229,7 +390,8 @@ public async Task ExistsFalse_File_Is_Ignored() { var insp = new Mock(MockBehavior.Strict); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); - insp.Setup(i => i.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(false); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -258,7 +420,8 @@ public async Task UnauthorizedAccess_Adds_Inaccessible_FileDescription() // File access triggers UnauthorizedAccess inside DoAnalyze(FileInfo) try/catch insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())) .Throws(new UnauthorizedAccessException("denied")); - insp.Setup(i => i.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -287,7 +450,8 @@ public async Task DirectoryNotFound_Adds_Inaccessible_FileDescription() 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")); - insp.Setup(i => i.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -317,7 +481,8 @@ public async Task IOException_Adds_Inaccessible_FileDescription() 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")); - insp.Setup(i => i.IsSystem(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(false); insp.Setup(i => i.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); @@ -345,7 +510,8 @@ public async Task Directory_IOException_Marked_Inaccessible_And_Skipped() { var insp = new Mock(MockBehavior.Strict); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); - insp.Setup(i => i.IsSystem(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.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); @@ -384,7 +550,8 @@ public async Task Directory_ReparsePoint_Is_Skipped() { var insp = new Mock(MockBehavior.Strict); insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); - insp.Setup(i => i.IsSystem(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.Exists(It.IsAny())).Returns(true); insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); @@ -414,3 +581,4 @@ public async Task Directory_ReparsePoint_Is_Skipped() part.FileDescriptions[0].RelativePath.Should().Be("/ok.txt"); } } +