diff --git a/.github/workflows/trigger-PR-pipeline.yml b/.github/workflows/trigger-PR-pipeline.yml index 119cf0353..ac49181eb 100644 --- a/.github/workflows/trigger-PR-pipeline.yml +++ b/.github/workflows/trigger-PR-pipeline.yml @@ -131,18 +131,51 @@ jobs: project="${{ matrix.testProject }}" safe_name=$(basename "$project" .csproj) echo "Running test project: $project" - dotnet test "$project" \ - --no-restore \ - --no-build \ - $extra_filter \ - --logger "trx;LogFileName=$safe_name.trx" \ - --results-directory ./TestResults \ + # Determine whether this is an IntegrationTests project + if [[ "$project" == *IntegrationTests* ]]; then + is_integration=true + else + is_integration=false + fi + + if [[ "$is_integration" == "true" && "${{ matrix.os }}" == "macos-latest" ]]; then + # macOS IntegrationTests: run without coverage but with diagnostics to find hangs + dotnet test "$project" \ + --no-restore \ + --no-build \ + $extra_filter \ + --logger "trx;LogFileName=$safe_name.trx" \ + --results-directory ./TestResults \ + --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type full \ + --diag ./TestResults/vstest-$safe_name.log \ + -- NUnit.NumberOfTestWorkers=1 + elif [[ "$is_integration" == "true" ]]; then + # IntegrationTests on Linux/Windows: include coverage for Sonar + dotnet test "$project" \ + --no-restore \ + --no-build \ + $extra_filter \ + --logger "trx;LogFileName=$safe_name.trx" \ + --results-directory ./TestResults \ --collect:"XPlat Code Coverage" \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover \ - DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.OutputDirectory=./TestResults + DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.OutputDirectory=./TestResults/${{ matrix.os }}/$safe_name + else + # UnitTests on all OS (including macOS): include coverage + dotnet test "$project" \ + --no-restore \ + --no-build \ + $extra_filter \ + --logger "trx;LogFileName=$safe_name.trx" \ + --results-directory ./TestResults \ + --collect:"XPlat Code Coverage" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover \ + DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.OutputDirectory=./TestResults/${{ matrix.os }}/$safe_name + fi shell: bash - name: Upload Test Results + if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: PR-test-results-${{ matrix.os }}-${{ hashFiles(matrix.testProject) }} diff --git a/docs/testing-permissions-and-symlinks.md b/docs/testing-permissions-and-symlinks.md new file mode 100644 index 000000000..fb0a51080 --- /dev/null +++ b/docs/testing-permissions-and-symlinks.md @@ -0,0 +1,286 @@ +# Testing Inaccessible Paths and Symlinks (Windows, Linux, macOS) + +This document provides safe, reproducible commands to turn a test directory into a non‑traversable/inaccessible path, and then restore it. +It also documents how to create symlinks on each OS. + +Use these recipes to validate inventory behavior (e.g., `IsAccessible` paths, continued scanning, validator blocks) without leaving your +machine in a broken state. + +## Quick scripts (run from current directory/Desktop) + +Run these from the directory you want to test in (e.g., your Desktop). They create a subdirectory, put a random‑content file inside, then +make the directory non‑traversable. A matching restore script makes it removable again. + +### Windows — PowerShell + +Create and make non‑traversable (stores original ACL SDDL next to the folder): + +``` +# create-nontraversable.ps1 +$name = "bytesync-test-" + (Get-Date -Format "yyyyMMddHHmmss") +$root = Join-Path (Get-Location) $name +New-Item -ItemType Directory -Path $root | Out-Null + +# Add a file with random content +Set-Content -Path (Join-Path $root 'file.txt') -Value ("content_" + (Get-Date -Format o)) + +# Save original ACL SDDL to a sidecar file for easy restore +$sddl = (Get-Acl $root).Sddl +Set-Content -Path ("$root.acl.sddl") -Value $sddl + +# Deny read & list (prevents traversal) for current user +$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$rights = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, + [System.Security.AccessControl.FileSystemRights]::ListDirectory +$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($user, $rights, 'None','None','Deny') +$acl = Get-Acl $root +$acl.AddAccessRule($rule) +Set-Acl -Path $root -AclObject $acl + +Write-Host "Created and locked: $root" -ForegroundColor Cyan +``` + +Restore (allows deletion): + +``` +# restore-removable.ps1 +param([string]$name) +if (-not $name) { throw "Usage: .\restore-removable.ps1 " } +$root = Join-Path (Get-Location) $name +$sddlPath = "$root.acl.sddl" +if (-not (Test-Path $sddlPath)) { throw "Missing sidecar SDDL file: $sddlPath" } + +$sddl = Get-Content -Path $sddlPath -Raw +$aclOriginal = New-Object System.Security.AccessControl.DirectorySecurity +$aclOriginal.SetSecurityDescriptorSddlForm($sddl) +Set-Acl -Path $root -AclObject $aclOriginal + +Write-Host "Restored ACLs for: $root" -ForegroundColor Green +``` + +Usage: + +``` +PS> cd "$env:USERPROFILE\Desktop" +PS> .\create-nontraversable.ps1 +# ... run inventory/tests targeting .\bytesync-test-YYYYMMDDhhmmss ... +PS> .\restore-removable.ps1 bytesync-test-YYYYMMDDhhmmss +PS> Remove-Item -Recurse -Force .\bytesync-test-YYYYMMDDhhmmss +``` + +### Linux / macOS — Bash + +Create and make non‑traversable: + +``` +# create-nontraversable.sh +name="bytesync-test-$(date +%Y%m%d%H%M%S)" +root="$PWD/$name" +mkdir -p "$root" +echo "content_$(date +%s)" > "$root/file.txt" + +# Remove all perms (owner can chmod back); prevents listing/traversal +chmod 000 "$root" +echo "Created and locked: $root" +``` + +Restore (allows deletion): + +``` +# restore-removable.sh +name="$1"; [ -z "$name" ] && { echo "Usage: restore-removable.sh "; exit 1; } +root="$PWD/$name" + +# Typical perms so the folder can be traversed/removed +chmod 755 "$root" +echo "Restored perms for: $root" +``` + +Usage: + +``` +$ cd ~/Desktop +$ bash create-nontraversable.sh +# ... run inventory/tests targeting ./bytesync-test-YYYYMMDDhhmmss ... +$ bash restore-removable.sh bytesync-test-YYYYMMDDhhmmss +$ rm -rf ./bytesync-test-YYYYMMDDhhmmss +``` + +> Tip: If you prefer using /tmp for ephemeral tests, run the same scripts from `/tmp`. + +--- + +## General Test Pattern + +1) Create a temporary test directory and content + +``` +# Windows (PowerShell) +$root = Join-Path $env:TEMP ("bytesync-test-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $root | Out-Null +Set-Content -Path (Join-Path $root 'file.txt') -Value 'hello' + +# Linux/macOS (bash) +root="/tmp/bytesync-test-$(uuidgen)"; mkdir -p "$root"; echo hello > "$root/file.txt" +``` + +2) Make the directory (or a child) non‑traversable/inaccessible (per OS below) + +3) Run the scenario (inventory/compare) + +4) Restore permissions (see Restore sections), then delete the directory + +``` +# Windows (PowerShell) +Remove-Item -Recurse -Force $root + +# Linux/macOS (bash) +rm -rf "$root" +``` + +--- + +## Windows (PowerShell / icacls) + +Windows volume roots (e.g. `C:\`) may surface as Hidden/System at the API level, even if File Explorer shows them. For permission testing, +prefer a subdirectory under `%TEMP%`. + +### Option A — Deny read/list on the test directory (PowerShell Set-Acl) + +This prevents listing/traversal (inventory calls will hit `UnauthorizedAccessException`). + +``` +# Save current ACL (SDDL) so you can restore later +$sddl = (Get-Acl $root).Sddl + +# Build a deny rule for the current user on Read & Execute + ListDirectory +$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$rights = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, + [System.Security.AccessControl.FileSystemRights]::ListDirectory +$inherit = [System.Security.AccessControl.InheritanceFlags]::None +$prop = [System.Security.AccessControl.PropagationFlags]::None +$deny = [System.Security.AccessControl.AccessControlType]::Deny +$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($user, $rights, $inherit, $prop, $deny) + +$acl = Get-Acl $root +$acl.AddAccessRule($rule) +Set-Acl -Path $root -AclObject $acl + +# ... run inventory/tests here ... + +# Restore original ACL (ensures deletion works) +$aclOriginal = New-Object System.Security.AccessControl.DirectorySecurity +$aclOriginal.SetSecurityDescriptorSddlForm($sddl) +Set-Acl -Path $root -AclObject $aclOriginal +``` + +Notes: + +- The owner can always take ownership/change DACL. Keeping the SDDL snapshot is the most reliable way to restore. +- If you also need to block inherited permissions, remove/disable inheritance before adding the deny: + `icacls "$root" /inheritance:d` + +### Option B — Deny read/list via icacls (alternative) + +``` +# Save ACLs to a file (relative paths from current dir) +Push-Location (Split-Path $root) +icacls "." /save acls.txt /t + +# Deny RX for current user on the test dir only +$u = "$env:USERDOMAIN\$env:USERNAME" +icacls "$root" /deny $u:(RX) + +# ... run inventory/tests here ... + +# Restore from saved file +icacls . /restore acls.txt +Pop-Location +``` + +### Make a single file inaccessible (optional) + +``` +$file = Join-Path $root 'file.txt' +$sddlFile = (Get-Acl $file).Sddl +$aclF = Get-Acl $file +$ruleF = New-Object System.Security.AccessControl.FileSystemAccessRule($user, 'Read', 'None', 'None', 'Deny') +$aclF.AddAccessRule($ruleF) +Set-Acl -Path $file -AclObject $aclF + +# ... + +$aclOrigF = New-Object System.Security.AccessControl.FileSecurity +$aclOrigF.SetSecurityDescriptorSddlForm($sddlFile) +Set-Acl -Path $file -AclObject $aclOrigF +``` + +--- + +## Linux / macOS (POSIX) + +On POSIX systems, directory traversal requires the execute (`x`) bit. Removing it makes the directory non‑traversable. Removing read (`r`) +prevents listing. + +### Make a directory non‑traversable + +``` +# Remove all permissions (owner can still chmod back) +chmod 000 "$root" + +# Alternatively, remove only execute bit(s) +chmod a-x "$root" +``` + +### Restore permissions + +``` +# Typical directory perms +chmod 755 "$root" +# or more conservative +chmod u+rwx,go+rx "$root" +``` + +### Make a file unreadable (optional) + +``` +chmod 000 "$root/file.txt" + +# Restore +chmod 644 "$root/file.txt" +``` + +Notes: + +- Deleting a directory entry depends on permissions on its parent, not the directory itself. If your test directory is under `/tmp` (owned + by you) you can still remove it after restoring perms. + +--- + +## Symlinks + +ByteSync ignores entries flagged as reparse points/symlinks during inventory to avoid following links inadvertently. + +### Create a symlink — Windows (PowerShell / CMD) + +``` +# PowerShell (requires Developer Mode or elevated rights depending on policy) +New-Item -ItemType SymbolicLink -Path "C:\path\to\link" -Target "C:\path\to\target" + +# CMD (directory symlink) +mklink /D C:\path\to\link C:\path\to\target +``` + +### Create a symlink — Linux/macOS (bash) + +``` +ln -s /path/to/target /path/to/link +``` + +--- + +## Troubleshooting + +- Windows: If Deny rules make the directory unreadable and inheritance complicates restore, revert using the saved SDDL or `icacls /restore` + from the parent directory. As the owner, you can always reset the ACL. +- POSIX: If you cannot traverse a directory, ensure you have write+execute on its parent and reset permissions with `chmod` as the owner. diff --git a/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs b/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs index d059417f1..9c5f13359 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs +++ b/src/ByteSync.Client/Assets/Resources/Resources.Designer.cs @@ -2313,6 +2313,12 @@ public static string UpdateDetails_Downloading { } } + public static string ContentIdentity_AccessIssueShortLabel { + get { + return ResourceManager.GetString("ContentIdentity_AccessIssueShortLabel", resourceCulture); + } + } + public static string UpdateDetails_Extracting { get { return ResourceManager.GetString("UpdateDetails_Extracting", resourceCulture); diff --git a/src/ByteSync.Client/Assets/Resources/Resources.fr.resx b/src/ByteSync.Client/Assets/Resources/Resources.fr.resx index 29bb6dfd9..9acdebec6 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.fr.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.fr.resx @@ -1669,6 +1669,21 @@ Voulez-vous enregistrer ce nouveau Profil de Session avec ce nom ? Action en double interdite + + L’élément source est inaccessible + + + Au moins une cible est inaccessible + + + Problème d’accès + + + Cet élément est présent mais inaccessible sur cet emplacement. Aucune synchronisation n’est possible. + + + Inaccessible + L'action ne peut pas être appliquée à certains éléments : diff --git a/src/ByteSync.Client/Assets/Resources/Resources.resx b/src/ByteSync.Client/Assets/Resources/Resources.resx index 0bf309834..d0078611f 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.resx @@ -1708,9 +1708,24 @@ Do you want to save this new Session Profile with this name? Cannot operate on item being deleted - - Duplicate action not allowed - + + Duplicate action not allowed + + + Source item is inaccessible + + + At least one target item is inaccessible + + + Access issue + + + This item is present but inaccessible on this endpoint. Synchronization is not allowed. + + + Inaccessible + The action cannot be applied to some items: diff --git a/src/ByteSync.Client/Business/Comparisons/AtomicActionValidationFailureReason.cs b/src/ByteSync.Client/Business/Comparisons/AtomicActionValidationFailureReason.cs index cd825260f..dbaa4f832 100644 --- a/src/ByteSync.Client/Business/Comparisons/AtomicActionValidationFailureReason.cs +++ b/src/ByteSync.Client/Business/Comparisons/AtomicActionValidationFailureReason.cs @@ -19,6 +19,7 @@ public enum AtomicActionValidationFailureReason // Advanced Consistency - Source Issues InvalidSourceCount = 30, SourceHasAnalysisError = 31, + SourceNotAccessible = 32, // Advanced Consistency - Target Issues TargetFileNotPresent = 40, @@ -26,6 +27,7 @@ public enum AtomicActionValidationFailureReason TargetRequiredForSynchronizeDateOrDelete = 42, CreateOperationRequiresDirectoryTarget = 43, TargetAlreadyExistsForCreateOperation = 44, + AtLeastOneTargetsNotAccessible = 45, // Advanced Consistency - Content Analysis NothingToCopyContentAndDateIdentical = 50, diff --git a/src/ByteSync.Client/DependencyInjection/Modules/AutoDetectionModule.cs b/src/ByteSync.Client/DependencyInjection/Modules/AutoDetectionModule.cs index e47477de3..9c70ea505 100644 --- a/src/ByteSync.Client/DependencyInjection/Modules/AutoDetectionModule.cs +++ b/src/ByteSync.Client/DependencyInjection/Modules/AutoDetectionModule.cs @@ -17,7 +17,8 @@ protected override void Load(ContainerBuilder builder) && t.Namespace != null && t.Namespace.StartsWith("ByteSync.Services") && !t.Name.EndsWith("Service") - && !t.Namespace.Contains("PushReceivers")) + && !t.Namespace.Contains("PushReceivers") + && !t.Namespace.Contains("ConditionMatchers")) .AsImplementedInterfaces(); builder.RegisterAssemblyTypes(executingAssembly) diff --git a/src/ByteSync.Client/DependencyInjection/Modules/ConditionMatchersModule.cs b/src/ByteSync.Client/DependencyInjection/Modules/ConditionMatchersModule.cs new file mode 100644 index 000000000..719478c50 --- /dev/null +++ b/src/ByteSync.Client/DependencyInjection/Modules/ConditionMatchersModule.cs @@ -0,0 +1,22 @@ +using Autofac; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using Module = Autofac.Module; + +namespace ByteSync.DependencyInjection.Modules; + +public class ConditionMatchersModule : Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().AsSelf().SingleInstance(); + + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + + builder.RegisterType().AsSelf().SingleInstance(); + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/DependencyInjection/ServiceRegistrar.cs b/src/ByteSync.Client/DependencyInjection/ServiceRegistrar.cs index e6c9b2788..d7712b31e 100644 --- a/src/ByteSync.Client/DependencyInjection/ServiceRegistrar.cs +++ b/src/ByteSync.Client/DependencyInjection/ServiceRegistrar.cs @@ -34,12 +34,13 @@ public static IContainer RegisterComponents() builder.RegisterModule(); builder.RegisterModule(); builder.RegisterModule(); - + builder.RegisterModule(); + builder.InitializeAvalonia(); - + var container = builder.Build(); ContainerProvider.Container = container; - + // Wire up the callback to break the circular dependency using (var scope = container.BeginLifetimeScope()) { @@ -50,7 +51,7 @@ public static IContainer RegisterComponents() fileDownloaderCache.OnPartsCoordinatorCreated = downloadManager.RegisterPartsCoordinator; } } - + using (var scope = container.BeginLifetimeScope()) { var environmentService = scope.Resolve(); @@ -59,14 +60,14 @@ public static IContainer RegisterComponents() return container; } } - + container.LogBootstrapHeader(); container.FeedClientId(); container.LogBootstrap(); - + return container; } - + private static void InitializeAvalonia(this ContainerBuilder builder) { var autofacResolver = builder.UseAutofacDependencyResolver(); @@ -76,7 +77,7 @@ private static void InitializeAvalonia(this ContainerBuilder builder) autofacResolver.InitializeReactiveUI(); RxApp.MainThreadScheduler = AvaloniaScheduler.Instance; } - + private static void FeedClientId(this IContainer container) { using var scope = container.BeginLifetimeScope(); diff --git a/src/ByteSync.Client/Factories/ViewModels/ContentIdentityViewModelFactory.cs b/src/ByteSync.Client/Factories/ViewModels/ContentIdentityViewModelFactory.cs index 0833cb6a2..6e45b35e6 100644 --- a/src/ByteSync.Client/Factories/ViewModels/ContentIdentityViewModelFactory.cs +++ b/src/ByteSync.Client/Factories/ViewModels/ContentIdentityViewModelFactory.cs @@ -1,4 +1,5 @@ using ByteSync.Interfaces.Factories.ViewModels; +using ByteSync.Interfaces.Services.Localizations; using ByteSync.Interfaces.Services.Sessions; using ByteSync.Models.Comparisons.Result; using ByteSync.Models.Inventories; @@ -11,17 +12,22 @@ public class ContentIdentityViewModelFactory : IContentIdentityViewModelFactory { private readonly ISessionService _sessionService; private readonly IDateAndInventoryPartsViewModelFactory _dateAndInventoryPartsViewModelFactory; - - public ContentIdentityViewModelFactory(ISessionService sessionService, IDateAndInventoryPartsViewModelFactory dateAndInventoryPartsViewModelFactory) + private readonly ILocalizationService _localizationService; + + public ContentIdentityViewModelFactory(ISessionService sessionService, + IDateAndInventoryPartsViewModelFactory dateAndInventoryPartsViewModelFactory, ILocalizationService localizationService) { _sessionService = sessionService; _dateAndInventoryPartsViewModelFactory = dateAndInventoryPartsViewModelFactory; + _localizationService = localizationService; } - public ContentIdentityViewModel CreateContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, ContentIdentity contentIdentity, Inventory inventory) + public ContentIdentityViewModel CreateContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, + ContentIdentity contentIdentity, Inventory inventory) { - var result = new ContentIdentityViewModel(comparisonItemViewModel, contentIdentity, inventory, _sessionService, _dateAndInventoryPartsViewModelFactory); - + var result = new ContentIdentityViewModel(comparisonItemViewModel, contentIdentity, inventory, _sessionService, + _localizationService, _dateAndInventoryPartsViewModelFactory); + return result; } } \ No newline at end of file diff --git a/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs new file mode 100644 index 000000000..fb54148fe --- /dev/null +++ b/src/ByteSync.Client/Interfaces/Controls/Inventories/IFileSystemInspector.cs @@ -0,0 +1,14 @@ +using System.IO; +using ByteSync.Common.Business.Misc; + +namespace ByteSync.Interfaces.Controls.Inventories; + +public interface IFileSystemInspector +{ + bool IsHidden(FileSystemInfo fsi, OSPlatforms os); + bool IsSystem(FileInfo fileInfo); + 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/Comparisons/Result/ContentIdentity.cs b/src/ByteSync.Client/Models/Comparisons/Result/ContentIdentity.cs index 8b762a817..f1ca5c0bf 100644 --- a/src/ByteSync.Client/Models/Comparisons/Result/ContentIdentity.cs +++ b/src/ByteSync.Client/Models/Comparisons/Result/ContentIdentity.cs @@ -8,125 +8,149 @@ public class ContentIdentity public ContentIdentity(ContentIdentityCore? contentIdentityCore) { Core = contentIdentityCore; - + FileSystemDescriptions = new HashSet(); InventoryPartsByCreationTimes = new Dictionary>(); InventoryPartsByLastWriteTimes = new Dictionary>(); FileSystemDescriptionsByInventoryParts = new Dictionary>(); + + AccessIssueInventoryParts = new HashSet(); } - + public ContentIdentityCore? Core { get; } - + public HashSet FileSystemDescriptions { get; } - + private Dictionary> InventoryPartsByCreationTimes { get; } - + public Dictionary> InventoryPartsByLastWriteTimes { get; } - + private Dictionary> FileSystemDescriptionsByInventoryParts { get; } - + + // Inventories/parts for which access is known to be an issue (e.g., via ancestor propagation) + public HashSet AccessIssueInventoryParts { get; } + public bool HasAnalysisError + { + get { return FileSystemDescriptions.Any(fsd => fsd is FileDescription { HasAnalysisError: true }); } + } + + public bool HasAccessIssue { get { - return FileSystemDescriptions.Any(fsd => fsd is FileDescription { HasAnalysisError: true }); + return FileSystemDescriptions.Any(fsd => fsd is FileDescription && !fsd.IsAccessible) + || AccessIssueInventoryParts.Count > 0; } } - - public bool HasManyFileSystemDescriptionOnAnInventoryPart + + public void AddAccessIssue(InventoryPart inventoryPart) { - get + AccessIssueInventoryParts.Add(inventoryPart); + } + + public bool HasAccessIssueFor(Inventory inventory) + { + // Either explicitly flagged via propagation + if (AccessIssueInventoryParts.Any(ip => ip.Inventory.Equals(inventory))) { - return FileSystemDescriptionsByInventoryParts.Any(p => p.Value.Count > 1); + return true; } + + // Or present FileDescriptions are marked inaccessible for this inventory + return FileSystemDescriptionsByInventoryParts + .Any(pair => pair.Key.Inventory.Equals(inventory) + && pair.Value.Any(fsd => fsd is FileDescription fd && !fd.IsAccessible)); } - + + public bool HasManyFileSystemDescriptionOnAnInventoryPart + { + get { return FileSystemDescriptionsByInventoryParts.Any(p => p.Value.Count > 1); } + } + protected bool Equals(ContentIdentity other) { return Equals(Core, other.Core); } - - public override bool Equals(object obj) + + public override bool Equals(object? obj) { - if (ReferenceEquals(null, obj)) return false; - if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != GetType()) return false; - return Equals((ContentIdentity) obj); + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((ContentIdentity)obj); } - + public override int GetHashCode() { return (Core != null ? Core.GetHashCode() : 0); } - + public override string ToString() { #if DEBUG - string toString = $"ContentIdentity {Core?.SignatureHash} {Core?.Size}"; - + var toString = $"ContentIdentity {Core?.SignatureHash} {Core?.Size}"; + foreach (var inventoryPartsByDate in InventoryPartsByLastWriteTimes) { - toString += $" - '{inventoryPartsByDate.Key:G}' {inventoryPartsByDate.Value.Select(ip => ip.RootName).ToList().JoinToString(", ")}"; + toString += + $" - '{inventoryPartsByDate.Key:G}' {inventoryPartsByDate.Value.Select(ip => ip.RootName).ToList().JoinToString(", ")}"; } - + return toString; #endif - + #pragma warning disable 162 return base.ToString(); #pragma warning restore 162 } - + public bool IsPresentIn(InventoryPart inventoryPart) { - foreach (var pair in FileSystemDescriptionsByInventoryParts) - { - if (pair.Key.Equals(inventoryPart)) - { - return true; - } - } - - return false; + return FileSystemDescriptionsByInventoryParts.Any(pair => pair.Key.Equals(inventoryPart)); } - + public bool IsPresentIn(Inventory inventory) { - foreach (var pair in FileSystemDescriptionsByInventoryParts) - { - if (pair.Key.Inventory.Equals(inventory)) - { - return true; - } - } - - return false; + return FileSystemDescriptionsByInventoryParts.Any(pair => pair.Key.Inventory.Equals(inventory)); } - + public HashSet GetInventories() { - HashSet inventories = new HashSet(); - + var inventories = new HashSet(); + foreach (var pair in FileSystemDescriptionsByInventoryParts) { inventories.Add(pair.Key.Inventory); } - + return inventories; } - + public HashSet GetInventoryParts() { - HashSet result = new HashSet(); - + var result = new HashSet(); + foreach (var pair in FileSystemDescriptionsByInventoryParts) { result.Add(pair.Key); } - + return result; } - + public DateTime? GetLastWriteTimeUtc(InventoryPart inventoryPart) { foreach (var pair in InventoryPartsByLastWriteTimes) @@ -136,10 +160,10 @@ public HashSet GetInventoryParts() return pair.Key; } } - + return null; } - + public DateTime? GetCreationTimeUtc(InventoryPart inventoryPart) { foreach (var pair in InventoryPartsByCreationTimes) @@ -149,47 +173,48 @@ public HashSet GetInventoryParts() return pair.Key; } } - + return null; } - + public void Add(FileSystemDescription fileSystemDescription) { FileSystemDescriptions.Add(fileSystemDescription); - + if (!FileSystemDescriptionsByInventoryParts.ContainsKey(fileSystemDescription.InventoryPart)) { FileSystemDescriptionsByInventoryParts.Add(fileSystemDescription.InventoryPart, new HashSet()); } + FileSystemDescriptionsByInventoryParts[fileSystemDescription.InventoryPart].Add(fileSystemDescription); - + if (fileSystemDescription is FileDescription fileDescription) { AddInventoryPartByCreationTime(fileSystemDescription.InventoryPart, fileDescription.CreationTimeUtc); AddInventoryPartByLastWriteTime(fileSystemDescription.InventoryPart, fileDescription.LastWriteTimeUtc); } } - + private void AddInventoryPartByCreationTime(InventoryPart inventoryPart, DateTime creationTimeUtc) { if (!InventoryPartsByCreationTimes.ContainsKey(creationTimeUtc)) { InventoryPartsByCreationTimes.Add(creationTimeUtc, new HashSet()); } - + InventoryPartsByCreationTimes[creationTimeUtc].Add(inventoryPart); } - + private void AddInventoryPartByLastWriteTime(InventoryPart inventoryPart, DateTime lastWriteTimeUtc) { if (!InventoryPartsByLastWriteTimes.ContainsKey(lastWriteTimeUtc)) { InventoryPartsByLastWriteTimes.Add(lastWriteTimeUtc, new HashSet()); } - + InventoryPartsByLastWriteTimes[lastWriteTimeUtc].Add(inventoryPart); } - + public HashSet GetFileSystemDescriptions(InventoryPart inventoryPart) { if (FileSystemDescriptionsByInventoryParts.TryGetValue(inventoryPart, out var result)) diff --git a/src/ByteSync.Client/Models/FileSystems/FileSystemDescription.cs b/src/ByteSync.Client/Models/FileSystems/FileSystemDescription.cs index 516930e52..d505147f2 100644 --- a/src/ByteSync.Client/Models/FileSystems/FileSystemDescription.cs +++ b/src/ByteSync.Client/Models/FileSystems/FileSystemDescription.cs @@ -20,6 +20,9 @@ protected FileSystemDescription(InventoryPart inventoryPart, string relativePath public string RelativePath { get; set; } + // Indicates if this item was accessible during inventory identification + public bool IsAccessible { get; set; } = true; + public abstract FileSystemTypes FileSystemType { get; } public Inventory Inventory diff --git a/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs b/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs index e5621d159..bcd69817d 100644 --- a/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs +++ b/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs @@ -5,13 +5,14 @@ using ByteSync.Interfaces.Controls.Comparisons; using ByteSync.Interfaces.Repositories; using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; namespace ByteSync.Services.Comparisons; public class AtomicActionConsistencyChecker : IAtomicActionConsistencyChecker { private readonly IAtomicActionRepository _atomicActionRepository; - + public AtomicActionConsistencyChecker(IAtomicActionRepository atomicActionRepository) { _atomicActionRepository = atomicActionRepository; @@ -21,7 +22,7 @@ public AtomicActionConsistencyCheckCanAddResult CheckCanAdd(AtomicAction atomicA { return CheckCanAdd(atomicAction, new List { comparisonItem }); } - + public AtomicActionConsistencyCheckCanAddResult CheckCanAdd(AtomicAction atomicAction, ICollection comparisonItems) { var result = new AtomicActionConsistencyCheckCanAddResult(comparisonItems); @@ -38,14 +39,14 @@ public AtomicActionConsistencyCheckCanAddResult CheckCanAdd(AtomicAction atomicA result.ValidationResults.Add(new ComparisonItemValidationResult(comparisonItem, validationResult.FailureReason!.Value)); } } - + return result; } - + public List GetApplicableActions(ICollection synchronizationRules) { - List applicableActions = new List(); - + var applicableActions = new List(); + var allActions = new List(); foreach (var synchronizationRule in synchronizationRules) { @@ -69,7 +70,7 @@ public List GetApplicableActions(ICollection } } } - + return applicableActions; } @@ -86,16 +87,16 @@ private AtomicActionValidationResult CanApply(AtomicAction atomicAction, Compari { return advancedConsistencyResult; } - + var consistencyAgainstAlreadySetActionsResult = CheckConsistencyAgainstAlreadySetActions(atomicAction, comparisonItem); if (!consistencyAgainstAlreadySetActionsResult.IsValid) { return consistencyAgainstAlreadySetActionsResult; } - + return AtomicActionValidationResult.Success(); } - + private static AtomicActionValidationResult CheckBasicConsistency(AtomicAction atomicAction, ComparisonItem comparisonItem) { if (atomicAction.Operator.In(ActionOperatorTypes.SynchronizeContentAndDate, ActionOperatorTypes.SynchronizeContentOnly, @@ -105,12 +106,12 @@ private static AtomicActionValidationResult CheckBasicConsistency(AtomicAction a { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SynchronizeOperationOnDirectoryNotAllowed); } - + if (atomicAction.Source == null) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SourceRequiredForSynchronizeOperation); } - + if (atomicAction.Destination == null) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DestinationRequiredForSynchronizeOperation); @@ -127,7 +128,7 @@ private static AtomicActionValidationResult CheckBasicConsistency(AtomicAction a { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SourceNotAllowedForDeleteOperation); } - + if (atomicAction.Destination == null) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DestinationRequiredForDeleteOperation); @@ -139,12 +140,12 @@ private static AtomicActionValidationResult CheckBasicConsistency(AtomicAction a { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.CreateOperationOnFileNotAllowed); } - + if (atomicAction.Source != null) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SourceNotAllowedForCreateOperation); } - + if (atomicAction.Destination == null) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DestinationRequiredForCreateOperation); @@ -154,25 +155,26 @@ private static AtomicActionValidationResult CheckBasicConsistency(AtomicAction a { throw new ApplicationException("AtomicActionConsistencyChecker: unknown action '{synchronizationAction.Action}'"); } - + return AtomicActionValidationResult.Success(); } private static AtomicActionValidationResult CheckAdvancedConsistency(AtomicAction atomicAction, ComparisonItem comparisonItem) { if (atomicAction.Operator.In(ActionOperatorTypes.SynchronizeContentAndDate, ActionOperatorTypes.SynchronizeContentOnly, - ActionOperatorTypes.SynchronizeDate)) + ActionOperatorTypes.SynchronizeDate)) { if (atomicAction.Source != null) { var sourceInventoryPart = atomicAction.Source.GetApplicableInventoryPart(); - + var contentIdentitiesSources = comparisonItem.GetContentIdentities(sourceInventoryPart); - + if (contentIdentitiesSources.Count != 1) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.InvalidSourceCount); } + var contentIdentitySource = contentIdentitiesSources.Single(); if (contentIdentitySource.HasAnalysisError) @@ -180,83 +182,108 @@ private static AtomicActionValidationResult CheckAdvancedConsistency(AtomicActio return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SourceHasAnalysisError); } + // Block if source is present but inaccessible + var sourceFsd = contentIdentitySource.GetFileSystemDescriptions(sourceInventoryPart); + if (sourceFsd.Any(fsd => fsd is FileDescription && !fsd.IsAccessible)) + { + return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SourceNotAccessible); + } + var targetInventoryPart = atomicAction.Destination!.GetApplicableInventoryPart(); var contentIdentityViewsTargets = comparisonItem.GetContentIdentities(targetInventoryPart); - + if (contentIdentityViewsTargets.Count == 0 && targetInventoryPart.InventoryPartType == FileSystemTypes.File) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.TargetFileNotPresent); } - - if (contentIdentitySource.InventoryPartsByLastWriteTimes.Count == 1 - && contentIdentityViewsTargets.Count > 0 - && contentIdentityViewsTargets.All(ci => ci.Core!.Equals(contentIdentitySource.Core!)) - && contentIdentityViewsTargets.All(ci => ci.InventoryPartsByLastWriteTimes.Count == 1 + + if (contentIdentitySource.InventoryPartsByLastWriteTimes.Count == 1 + && contentIdentityViewsTargets.Count > 0 + && contentIdentitySource.Core != null + && contentIdentityViewsTargets.All(ci => ci.Core != null && ci.Core.Equals(contentIdentitySource.Core)) + && contentIdentityViewsTargets.All(ci => ci.InventoryPartsByLastWriteTimes.Count == 1 && ci.InventoryPartsByLastWriteTimes.Keys.Single() - .Equals(contentIdentitySource.InventoryPartsByLastWriteTimes.Keys.Single()))) + .Equals(contentIdentitySource.InventoryPartsByLastWriteTimes.Keys + .Single()))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.NothingToCopyContentAndDateIdentical); } - + if (contentIdentityViewsTargets.Count > 0 && contentIdentityViewsTargets.Any(t => t.HasAnalysisError)) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.AtLeastOneTargetsHasAnalysisError); } + // Block if at least one target is present but inaccessible + if (contentIdentityViewsTargets.Count > 0 && contentIdentityViewsTargets + .Any(t => t.GetFileSystemDescriptions(targetInventoryPart).Any(fsd => fsd is FileDescription && !fsd.IsAccessible))) + { + return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + } + if (atomicAction.IsSynchronizeContentOnly && contentIdentityViewsTargets.Count != 0 && - contentIdentityViewsTargets.All(t => contentIdentitySource.Core!.Equals(t.Core!))) + contentIdentitySource.Core != null && + contentIdentityViewsTargets.All(t => t.Core != null && contentIdentitySource.Core.Equals(t.Core))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.NothingToCopyContentIdentical); } } } - + if (atomicAction.IsSynchronizeDate || atomicAction.IsDelete) { var targetInventoryPart = atomicAction.Destination!.GetApplicableInventoryPart(); var contentIdentitiesTargets = comparisonItem.GetContentIdentities(targetInventoryPart); - + if (contentIdentitiesTargets.Count == 0) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.TargetRequiredForSynchronizeDateOrDelete); } - } + // Block if any target is inaccessible + if (contentIdentitiesTargets.Any(t => + t.GetFileSystemDescriptions(targetInventoryPart).Any(fsd => fsd is FileDescription && !fsd.IsAccessible))) + { + return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + } + } + if (atomicAction.IsCreate) { var targetInventoryPart = atomicAction.Destination!.GetApplicableInventoryPart(); - + if (targetInventoryPart.InventoryPartType == FileSystemTypes.File) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.CreateOperationRequiresDirectoryTarget); } - + var contentIdentitiesTargets = comparisonItem.GetContentIdentities(targetInventoryPart); - + if (contentIdentitiesTargets.Count != 0) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.TargetAlreadyExistsForCreateOperation); } } - + return AtomicActionValidationResult.Success(); } - + private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(AtomicAction atomicAction, ComparisonItem comparisonItem) { - List alreadySetAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem); - + var alreadySetAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem) ?? new List(); + if (atomicAction.IsTargeted) { alreadySetAtomicActions = alreadySetAtomicActions .Where(a => a.IsTargeted) .ToList(); } - + return CheckConsistencyAgainstAlreadySetActions(atomicAction, alreadySetAtomicActions); } - - private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(AtomicAction atomicAction, List alreadySetAtomicActions) + + private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(AtomicAction atomicAction, + List alreadySetAtomicActions) { if (alreadySetAtomicActions.Count == 0) { @@ -265,16 +292,17 @@ private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(At if (!atomicAction.IsTargeted && alreadySetAtomicActions.Any(a => a.IsDoNothing)) { - return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.NonTargetedActionNotAllowedWithExistingDoNothingAction); + return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason + .NonTargetedActionNotAllowedWithExistingDoNothingAction); } - + if (alreadySetAtomicActions.Any(ma => !atomicAction.IsDelete && // 16/02/2023: What is the purpose of this IsDelete? Equals(ma.Destination, atomicAction.Source))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.SourceCannotBeDestinationOfAnotherAction); } - + if (alreadySetAtomicActions.Any(ma => Equals(ma.Source, atomicAction.Destination))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DestinationCannotBeSourceOfAnotherAction); @@ -285,39 +313,42 @@ private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(At if (alreadySetAtomicActions.Count == 1) { var alreadySetAtomicAction = alreadySetAtomicActions.Single(); - + if ((!alreadySetAtomicAction.IsSynchronizeDate || !atomicAction.IsSynchronizeContentOnly) && (!alreadySetAtomicAction.IsSynchronizeContentOnly || !atomicAction.IsSynchronizeDate)) { - return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DestinationAlreadyUsedByNonComplementaryAction); + return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason + .DestinationAlreadyUsedByNonComplementaryAction); } } else { - return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DestinationAlreadyUsedByNonComplementaryAction); + return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason + .DestinationAlreadyUsedByNonComplementaryAction); } } - + if (atomicAction.Operator == ActionOperatorTypes.Delete) { - if (alreadySetAtomicActions.Any(ma => + if (alreadySetAtomicActions.Any(ma => Equals(ma.Destination, atomicAction.Destination) || Equals(ma.Source, atomicAction.Destination))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.CannotDeleteItemAlreadyUsedInAnotherAction); } } - + if (alreadySetAtomicActions.Any(ma => ma.Operator == ActionOperatorTypes.Delete && - (Equals(ma.Destination, atomicAction.Destination) || Equals(ma.Destination, atomicAction.Source)))) + (Equals(ma.Destination, atomicAction.Destination) || + Equals(ma.Destination, atomicAction.Source)))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.CannotOperateOnItemBeingDeleted); } - + if (atomicAction.Operator != ActionOperatorTypes.DoNothing && alreadySetAtomicActions.Any(s => s.IsSimilarTo(atomicAction))) { return AtomicActionValidationResult.Failure(AtomicActionValidationFailureReason.DuplicateActionNotAllowed); } - + return AtomicActionValidationResult.Success(); } } \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/ConditionMatcherFactory.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/ConditionMatcherFactory.cs new file mode 100644 index 000000000..29da38bcc --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/ConditionMatcherFactory.cs @@ -0,0 +1,34 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public class ConditionMatcherFactory +{ + private readonly Dictionary _matchers; + + public ConditionMatcherFactory(IEnumerable matchers) + { + _matchers = matchers + .GroupBy(m => m.SupportedProperty) + .ToDictionary(g => g.Key, g => g.First()); + } + + public IConditionMatcher GetMatcher(ComparisonProperty property) + { + if (_matchers.TryGetValue(property, out var matcher)) + { + return matcher; + } + + return new NullConditionMatcher(); + } + + private class NullConditionMatcher : IConditionMatcher + { + public ComparisonProperty SupportedProperty => (ComparisonProperty)0; + + public bool Matches(AtomicCondition condition, ComparisonItem comparisonItem) => false; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/ContentConditionMatcher.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/ContentConditionMatcher.cs new file mode 100644 index 000000000..45af4d645 --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/ContentConditionMatcher.cs @@ -0,0 +1,79 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public class ContentConditionMatcher : IConditionMatcher +{ + private readonly ContentIdentityExtractor _extractor; + + public ContentConditionMatcher(ContentIdentityExtractor extractor) + { + _extractor = extractor; + } + + public ComparisonProperty SupportedProperty => ComparisonProperty.Content; + + public bool Matches(AtomicCondition condition, ComparisonItem comparisonItem) + { + bool? result = null; + + if (comparisonItem.FileSystemType == FileSystemTypes.Directory) + { + return false; + } + + var contentIdentitySource = _extractor.ExtractContentIdentity(condition.Source, comparisonItem); + var contentIdentityDestination = _extractor.ExtractContentIdentity(condition.Destination, comparisonItem); + + if ((contentIdentitySource != null && (contentIdentitySource.HasAnalysisError || contentIdentitySource.HasAccessIssue)) + || (contentIdentityDestination != null && + (contentIdentityDestination.HasAnalysisError || contentIdentityDestination.HasAccessIssue))) + { + return false; + } + + switch (condition.ConditionOperator) + { + case ConditionOperatorTypes.Equals: + if (contentIdentitySource == null && contentIdentityDestination != null) + { + result = false; + } + else if (contentIdentitySource != null && contentIdentityDestination == null) + { + result = false; + } + else + { + result = Equals(contentIdentitySource?.Core!.SignatureHash, contentIdentityDestination?.Core!.SignatureHash); + } + + break; + case ConditionOperatorTypes.NotEquals: + if (contentIdentitySource == null && contentIdentityDestination != null) + { + result = true; + } + else if (contentIdentitySource != null && contentIdentityDestination == null) + { + result = true; + } + else + { + result = !Equals(contentIdentitySource?.Core!.SignatureHash, contentIdentityDestination?.Core!.SignatureHash); + } + + break; + } + + if (result == null) + { + throw new ArgumentOutOfRangeException("ConditionMatchesContent " + condition.ConditionOperator); + } + + return result.Value; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/DateConditionMatcher.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/DateConditionMatcher.cs new file mode 100644 index 000000000..dcd934e3a --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/DateConditionMatcher.cs @@ -0,0 +1,66 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public class DateConditionMatcher : IConditionMatcher +{ + private readonly ContentIdentityExtractor _extractor; + + public DateConditionMatcher(ContentIdentityExtractor extractor) + { + _extractor = extractor; + } + + public ComparisonProperty SupportedProperty => ComparisonProperty.Date; + + public bool Matches(AtomicCondition condition, ComparisonItem comparisonItem) + { + var lastWriteTimeSource = _extractor.ExtractDate(condition.Source, comparisonItem); + + DateTime? lastWriteTimeDestination; + if (condition.Destination is { IsVirtual: false }) + { + lastWriteTimeDestination = _extractor.ExtractDate(condition.Destination, comparisonItem); + } + else + { + lastWriteTimeDestination = condition.DateTime!.Value.ToUniversalTime(); + + if (lastWriteTimeSource is { Second: 0, Millisecond: 0 }) + { + lastWriteTimeSource = lastWriteTimeSource.Value.Trim(TimeSpan.TicksPerMinute); + } + } + + if (lastWriteTimeSource == null) + { + return false; + } + + var result = false; + switch (condition.ConditionOperator) + { + case ConditionOperatorTypes.Equals: + result = lastWriteTimeDestination != null && lastWriteTimeSource == lastWriteTimeDestination; + + break; + case ConditionOperatorTypes.NotEquals: + result = lastWriteTimeDestination != null && lastWriteTimeSource != lastWriteTimeDestination; + + break; + case ConditionOperatorTypes.IsNewerThan: + result = (condition.Destination is { IsVirtual: false } && lastWriteTimeDestination == null) || + (lastWriteTimeDestination != null && lastWriteTimeSource > lastWriteTimeDestination); + + break; + case ConditionOperatorTypes.IsOlderThan: + result = lastWriteTimeDestination != null && lastWriteTimeSource < lastWriteTimeDestination; + + break; + } + + return result; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/IConditionMatcher.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/IConditionMatcher.cs new file mode 100644 index 000000000..fae0c1b7c --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/IConditionMatcher.cs @@ -0,0 +1,12 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public interface IConditionMatcher +{ + ComparisonProperty SupportedProperty { get; } + + bool Matches(AtomicCondition condition, ComparisonItem comparisonItem); +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/NameConditionMatcher.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/NameConditionMatcher.cs new file mode 100644 index 000000000..dcfae3a9d --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/NameConditionMatcher.cs @@ -0,0 +1,49 @@ +using System.Text.RegularExpressions; +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public class NameConditionMatcher : IConditionMatcher +{ + public ComparisonProperty SupportedProperty => ComparisonProperty.Name; + + public bool Matches(AtomicCondition condition, ComparisonItem comparisonItem) + { + if (string.IsNullOrWhiteSpace(condition.NamePattern)) + { + return false; + } + + var name = comparisonItem.PathIdentity.FileName; + var pattern = condition.NamePattern!; + + var result = false; + + if (pattern.Contains("*") && + condition.ConditionOperator.In(ConditionOperatorTypes.Equals, ConditionOperatorTypes.NotEquals)) + { + var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$"; + var safeRegex = new Regex(regex, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(500)); + var isMatch = safeRegex.IsMatch(name); + result = condition.ConditionOperator == ConditionOperatorTypes.Equals ? isMatch : !isMatch; + } + else + { + switch (condition.ConditionOperator) + { + case ConditionOperatorTypes.Equals: + result = string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase); + + break; + case ConditionOperatorTypes.NotEquals: + result = !string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase); + + break; + } + } + + return result; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/PresenceConditionMatcher.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/PresenceConditionMatcher.cs new file mode 100644 index 000000000..6585c4181 --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/PresenceConditionMatcher.cs @@ -0,0 +1,47 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public class PresenceConditionMatcher : IConditionMatcher +{ + private readonly ContentIdentityExtractor _extractor; + + public PresenceConditionMatcher(ContentIdentityExtractor extractor) + { + _extractor = extractor; + } + + public ComparisonProperty SupportedProperty => ComparisonProperty.Presence; + + public bool Matches(AtomicCondition condition, ComparisonItem comparisonItem) + { + bool? result = null; + + if (condition.ConditionOperator.In(ConditionOperatorTypes.ExistsOn, ConditionOperatorTypes.NotExistsOn)) + { + var existsOnSource = _extractor.ExistsOn(condition.Source, comparisonItem); + var existsOnDestination = _extractor.ExistsOn(condition.Destination, comparisonItem); + + switch (condition.ConditionOperator) + { + case ConditionOperatorTypes.ExistsOn: + result = existsOnSource && existsOnDestination; + + break; + case ConditionOperatorTypes.NotExistsOn: + result = existsOnSource && !existsOnDestination; + + break; + } + } + + if (result == null) + { + throw new ArgumentOutOfRangeException("ConditionMatchesPresence " + condition.ConditionOperator); + } + + return result.Value; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/SizeConditionMatcher.cs b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/SizeConditionMatcher.cs new file mode 100644 index 000000000..a794bcc81 --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ConditionMatchers/SizeConditionMatcher.cs @@ -0,0 +1,63 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons.ConditionMatchers; + +public class SizeConditionMatcher : IConditionMatcher +{ + private readonly ContentIdentityExtractor _extractor; + + public SizeConditionMatcher(ContentIdentityExtractor extractor) + { + _extractor = extractor; + } + + public ComparisonProperty SupportedProperty => ComparisonProperty.Size; + + public bool Matches(AtomicCondition condition, ComparisonItem comparisonItem) + { + var sizeSource = _extractor.ExtractSize(condition.Source, comparisonItem); + + long? sizeDestination; + if (condition.Destination is { IsVirtual: false }) + { + sizeDestination = _extractor.ExtractSize(condition.Destination, comparisonItem); + } + else + { + var size = (long)condition.Size!; + var sizeUnitPower = (int)condition.SizeUnit! - 1; + + sizeDestination = size * (long)Math.Pow(1024, sizeUnitPower); + } + + if (sizeSource == null || sizeDestination == null) + { + return false; + } + + var result = false; + switch (condition.ConditionOperator) + { + case ConditionOperatorTypes.Equals: + result = sizeSource == sizeDestination; + + break; + case ConditionOperatorTypes.NotEquals: + result = sizeSource != sizeDestination; + + break; + case ConditionOperatorTypes.IsSmallerThan: + result = sizeSource < sizeDestination; + + break; + case ConditionOperatorTypes.IsBiggerThan: + result = sizeSource > sizeDestination; + + break; + } + + return result; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ContentIdentityExtractor.cs b/src/ByteSync.Client/Services/Comparisons/ContentIdentityExtractor.cs new file mode 100644 index 000000000..cea2283be --- /dev/null +++ b/src/ByteSync.Client/Services/Comparisons/ContentIdentityExtractor.cs @@ -0,0 +1,70 @@ +using ByteSync.Business.Comparisons; +using ByteSync.Models.Comparisons.Result; + +namespace ByteSync.Services.Comparisons; + +public class ContentIdentityExtractor +{ + public ContentIdentity? ExtractContentIdentity(DataPart? dataPart, ComparisonItem comparisonItem) + { + if (dataPart == null) + { + return null; + } + + return LocalizeContentIdentity(dataPart, comparisonItem); + } + + public long? ExtractSize(DataPart dataPart, ComparisonItem comparisonItem) + { + var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); + + return contentIdentity?.Core?.Size; + } + + public DateTime? ExtractDate(DataPart dataPart, ComparisonItem comparisonItem) + { + var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); + + if (contentIdentity != null) + { + foreach (var pair in contentIdentity.InventoryPartsByLastWriteTimes) + { + if (pair.Value.Contains(dataPart.GetApplicableInventoryPart())) + { + return pair.Key; + } + } + } + + return null; + } + + public ContentIdentity? LocalizeContentIdentity(DataPart dataPart, ComparisonItem comparisonItem) + { + if (dataPart.Inventory != null) + { + return comparisonItem.ContentIdentities + .FirstOrDefault(ci => ci.GetInventories().Contains(dataPart.Inventory)); + } + else if (dataPart.InventoryPart != null) + { + return comparisonItem.ContentIdentities + .FirstOrDefault(ci => ci.GetInventoryParts().Contains(dataPart.InventoryPart)); + } + + return null; + } + + public bool ExistsOn(DataPart? dataPart, ComparisonItem comparisonItem) + { + if (dataPart == null) + { + return false; + } + + var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); + + return contentIdentity != null; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Comparisons/ContentRepartitionGroupsComputer.cs b/src/ByteSync.Client/Services/Comparisons/ContentRepartitionGroupsComputer.cs index ed0e6b6d5..865500af8 100644 --- a/src/ByteSync.Client/Services/Comparisons/ContentRepartitionGroupsComputer.cs +++ b/src/ByteSync.Client/Services/Comparisons/ContentRepartitionGroupsComputer.cs @@ -34,7 +34,7 @@ public ContentRepartitionComputeResult Compute() ContentRepartitionViewModel.LastWriteTimeGroups!.Clear(); ContentRepartitionViewModel.PresenceGroups!.Clear(); - ContentRepartitionComputeResult result = new ContentRepartitionComputeResult(ContentRepartitionViewModel.FileSystemType); + var result = new ContentRepartitionComputeResult(ContentRepartitionViewModel.FileSystemType); if (ContentRepartitionViewModel.FileSystemType == FileSystemTypes.File) { @@ -144,7 +144,7 @@ private List ComputeMembers(Dictionary ComputePresenceMembers(Dictionary> statusFingerPrintGroups) + private List ComputePresenceMembers(Dictionary> _) { var result = new List(); diff --git a/src/ByteSync.Client/Services/Comparisons/InitialStatusBuilder.cs b/src/ByteSync.Client/Services/Comparisons/InitialStatusBuilder.cs index 9ac6e484c..ed8026c8d 100644 --- a/src/ByteSync.Client/Services/Comparisons/InitialStatusBuilder.cs +++ b/src/ByteSync.Client/Services/Comparisons/InitialStatusBuilder.cs @@ -49,15 +49,15 @@ private void BuildInitialStatusForFile(ComparisonItem comparisonItem) { if (comparisonItem.FileSystemType == FileSystemTypes.File) { - if (!comparisonItem.ContentRepartition.FingerPrintGroups.ContainsKey(contentIdentity.Core)) + if (!comparisonItem.ContentRepartition.FingerPrintGroups.ContainsKey(contentIdentity.Core!)) { - comparisonItem.ContentRepartition.FingerPrintGroups.Add(contentIdentity.Core, new HashSet()); + comparisonItem.ContentRepartition.FingerPrintGroups.Add(contentIdentity.Core!, new HashSet()); } } - foreach (KeyValuePair> pair in contentIdentity.InventoryPartsByLastWriteTimes) + foreach (var pair in contentIdentity.InventoryPartsByLastWriteTimes) { - comparisonItem.ContentRepartition.FingerPrintGroups[contentIdentity.Core].AddAll(pair.Value); + comparisonItem.ContentRepartition.FingerPrintGroups[contentIdentity.Core!].AddAll(pair.Value); foreach (var inventoryPart in pair.Value) { @@ -86,8 +86,8 @@ private void BuildInitialStatusForFile(ComparisonItem comparisonItem) private void BuildInitialStatusForDirectory(ComparisonItem comparisonItem) { - HashSet inventoriesOK = new HashSet(); - HashSet inventoryPartsOK = new HashSet(); + var inventoriesOK = new HashSet(); + var inventoryPartsOK = new HashSet(); foreach (var contentIdentity in comparisonItem.ContentIdentities) { diff --git a/src/ByteSync.Client/Services/Comparisons/InventoryComparer.cs b/src/ByteSync.Client/Services/Comparisons/InventoryComparer.cs index 5b748f990..c7dad4924 100644 --- a/src/ByteSync.Client/Services/Comparisons/InventoryComparer.cs +++ b/src/ByteSync.Client/Services/Comparisons/InventoryComparer.cs @@ -4,6 +4,7 @@ using ByteSync.Interfaces.Controls.Inventories; using ByteSync.Models.Comparisons.Result; using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; using ByteSync.Services.Inventories; namespace ByteSync.Services.Comparisons; @@ -85,6 +86,12 @@ public ComparisonResult Compare() _initialStatusBuilder.BuildStatus(comparisonItem, InventoryLoaders.Select(il => il.Inventory)); } + // Propagate access issues from inaccessible ancestor directories (Tree mode only) + if (SessionSettings.MatchingMode == MatchingModes.Tree) + { + PropagateAccessIssuesFromAncestors(); + } + return ComparisonResult; } @@ -143,6 +150,133 @@ private void HandleDirectoryDescription(DirectoryDescription directoryDescriptio contentIdentity.Add(directoryDescription); } + private void PropagateAccessIssuesFromAncestors() + { + var inaccessibleByPart = BuildInaccessibleDirectoriesByPart(); + + foreach (var item in ComparisonResult.ComparisonItems) + { + var relative = item.PathIdentity.LinkingKeyValue; + if (string.IsNullOrWhiteSpace(relative) || relative == "/") + { + continue; + } + + var partsWithInaccessibleAncestor = FindPartsWithInaccessibleAncestor(relative, inaccessibleByPart); + if (partsWithInaccessibleAncestor.Count == 0) + { + continue; + } + + if (item.FileSystemType == FileSystemTypes.File) + { + HandleFileWithInaccessibleAncestor(item, relative, partsWithInaccessibleAncestor); + } + else + { + HandleDirectoryWithInaccessibleAncestor(item, partsWithInaccessibleAncestor); + } + } + } + + private Dictionary> BuildInaccessibleDirectoriesByPart() + { + var inaccessibleByPart = new Dictionary>(); + + foreach (var loader in InventoryLoaders) + { + foreach (var part in loader.Inventory.InventoryParts) + { + var inaccessibleDirs = new HashSet(StringComparer.Ordinal); + foreach (var dir in part.DirectoryDescriptions) + { + if (!dir.IsAccessible) + { + inaccessibleDirs.Add(dir.RelativePath); + } + } + + inaccessibleByPart[part] = inaccessibleDirs; + } + } + + return inaccessibleByPart; + } + + private HashSet FindPartsWithInaccessibleAncestor(string relativePath, + Dictionary> inaccessibleByPart) + { + var partsWithInaccessibleAncestor = new HashSet(); + + foreach (var loader in InventoryLoaders) + { + foreach (var part in loader.Inventory.InventoryParts) + { + if (IsUnderInaccessibleAncestor(relativePath, inaccessibleByPart[part])) + { + partsWithInaccessibleAncestor.Add(part); + } + } + } + + return partsWithInaccessibleAncestor; + } + + private void HandleFileWithInaccessibleAncestor(ComparisonItem item, string relativePath, + HashSet partsWithInaccessibleAncestor) + { + var partsWithContent = item.ContentIdentities + .SelectMany(ci => ci.GetInventoryParts()) + .ToHashSet(); + + foreach (var part in partsWithInaccessibleAncestor) + { + if (!partsWithContent.Contains(part)) + { + var virtualContentIdentity = new ContentIdentity(null); + var virtualFileDescription = new FileDescription(part, relativePath) + { + IsAccessible = false + }; + + virtualContentIdentity.Add(virtualFileDescription); + virtualContentIdentity.AddAccessIssue(part); + item.AddContentIdentity(virtualContentIdentity); + } + } + } + + private static void HandleDirectoryWithInaccessibleAncestor(ComparisonItem item, HashSet partsWithInaccessibleAncestor) + { + foreach (var part in partsWithInaccessibleAncestor) + { + foreach (var ci in item.ContentIdentities) + { + ci.AddAccessIssue(part); + } + } + } + + private static bool IsUnderInaccessibleAncestor(string relativePath, HashSet inaccessibleDirs) + { + // Walk parents: /a/b/c.txt -> /a/b -> /a + var path = relativePath; + while (true) + { + var idx = path.LastIndexOf('/'); + if (idx <= 0) + { + return false; + } + + path = path.Substring(0, idx); // drop last segment + if (inaccessibleDirs.Contains(path)) + { + return true; + } + } + } + private PathIdentity BuildPathIdentity(FileSystemDescription fileSystemDescription) { string linkingData; diff --git a/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs b/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs index b1b9b75a4..2446e499f 100644 --- a/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs +++ b/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs @@ -1,11 +1,10 @@ using ByteSync.Business.Actions.Local; using ByteSync.Business.Comparisons; -using ByteSync.Common.Business.Inventories; using ByteSync.Interfaces.Controls.Comparisons; using ByteSync.Interfaces.Controls.Synchronizations; using ByteSync.Interfaces.Repositories; using ByteSync.Models.Comparisons.Result; -using System.Text.RegularExpressions; +using ByteSync.Services.Comparisons.ConditionMatchers; namespace ByteSync.Services.Comparisons; @@ -13,14 +12,17 @@ public class SynchronizationRuleMatcher : ISynchronizationRuleMatcher { private readonly IAtomicActionConsistencyChecker _atomicActionConsistencyChecker; private readonly IAtomicActionRepository _atomicActionRepository; - - public SynchronizationRuleMatcher(IAtomicActionConsistencyChecker atomicActionConsistencyChecker, - IAtomicActionRepository atomicActionRepository) + private readonly ConditionMatcherFactory _conditionMatcherFactory; + + public SynchronizationRuleMatcher(IAtomicActionConsistencyChecker atomicActionConsistencyChecker, + IAtomicActionRepository atomicActionRepository, + ConditionMatcherFactory conditionMatcherFactory) { _atomicActionConsistencyChecker = atomicActionConsistencyChecker; _atomicActionRepository = atomicActionRepository; + _conditionMatcherFactory = conditionMatcherFactory; } - + public void MakeMatches(ICollection comparisonItems, ICollection synchronizationRules) { var allAtomicActions = new HashSet(); @@ -32,29 +34,30 @@ public void MakeMatches(ICollection comparisonItems, ICollection _atomicActionRepository.AddOrUpdate(allAtomicActions); } - + public void MakeMatches(ComparisonItem comparisonItem, ICollection synchronizationRules) { var atomicActions = DoMakeMatches(comparisonItem, synchronizationRules); _atomicActionRepository.AddOrUpdate(atomicActions); } - + private HashSet DoMakeMatches(ComparisonItem comparisonItem, ICollection synchronizationRules) { var initialAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem); var actionsToRemove = initialAtomicActions.Where(a => a.IsFromSynchronizationRule).ToList(); _atomicActionRepository.Remove(actionsToRemove); - HashSet atomicActions = GetApplicableActions(comparisonItem, synchronizationRules); + var atomicActions = GetApplicableActions(comparisonItem, synchronizationRules); + return atomicActions; } - - private HashSet GetApplicableActions(ComparisonItem comparisonItem, + + private HashSet GetApplicableActions(ComparisonItem comparisonItem, ICollection synchronizationRules) { - HashSet result = new HashSet(); - + var result = new HashSet(); + var matchingSynchronizationRules = synchronizationRules.Where(sr => ConditionsMatch(sr, comparisonItem)).ToList(); var atomicActions = _atomicActionConsistencyChecker.GetApplicableActions(matchingSynchronizationRules); @@ -69,357 +72,33 @@ private HashSet GetApplicableActions(ComparisonItem comparisonItem result.Add(clonedAtomicAction); } } - + return result; } - - private bool ConditionsMatch(SynchronizationRule synchronizationRule, ComparisonItem comparisonItem) + + public bool ConditionsMatch(SynchronizationRule synchronizationRule, ComparisonItem comparisonItem) { if (synchronizationRule.Conditions.Count == 0) { return false; } - + if (synchronizationRule.FileSystemType != comparisonItem.FileSystemType) { return false; } - - var areAllConditionsOK = true; - var isOneConditionOK = false; - - foreach (var condition in synchronizationRule.Conditions) - { - var isConditionOK = ConditionMatches(condition, comparisonItem); - - if (!isConditionOK) - { - areAllConditionsOK = false; - } - else - { - isOneConditionOK = true; - } - } - - if (synchronizationRule.ConditionMode == ConditionModes.All) - { - return areAllConditionsOK; - } - else - { - return isOneConditionOK; - } - } - - private bool ConditionMatches(AtomicCondition condition, ComparisonItem comparisonItem) - { - switch (condition.ComparisonProperty) - { - case ComparisonProperty.Content: - return ConditionMatchesContent(condition, comparisonItem); - case ComparisonProperty.Size: - return ConditionMatchesSize(condition, comparisonItem); - case ComparisonProperty.Date: - return ConditionMatchesDate(condition, comparisonItem); - case ComparisonProperty.Presence: - return ConditionMatchesPresence(condition, comparisonItem); - case ComparisonProperty.Name: - return ConditionMatchesName(condition, comparisonItem); - default: - return false; - } - } - - private bool ConditionMatchesContent(AtomicCondition condition, ComparisonItem comparisonItem) - { - bool? result = null; - - if (comparisonItem.FileSystemType == FileSystemTypes.Directory) - { - return false; - } - - var contentIdentitySource = ExtractContentIdentity(condition.Source, comparisonItem); - var contentIdentityDestination = ExtractContentIdentity(condition.Destination, comparisonItem); - - if (contentIdentitySource != null && contentIdentitySource.HasAnalysisError - || contentIdentityDestination != null && contentIdentityDestination.HasAnalysisError) - { - return false; - } - - switch (condition.ConditionOperator) - { - case ConditionOperatorTypes.Equals: - if (contentIdentitySource == null && contentIdentityDestination != null) - { - result = false; - } - else if (contentIdentitySource != null && contentIdentityDestination == null) - { - result = false; - } - else - { - result = Equals(contentIdentitySource?.Core!.SignatureHash, contentIdentityDestination?.Core!.SignatureHash); - } - - break; - case ConditionOperatorTypes.NotEquals: - if (contentIdentitySource == null && contentIdentityDestination != null) - { - result = true; - } - else if (contentIdentitySource != null && contentIdentityDestination == null) - { - result = true; - } - else - { - result = ! Equals(contentIdentitySource?.Core!.SignatureHash, contentIdentityDestination?.Core!.SignatureHash); - } - break; - } - - if (result == null) - { - throw new ArgumentOutOfRangeException("ConditionMatchesContent " + condition.ConditionOperator); - } - - return result.Value; - } - private bool ExistsOn(DataPart? dataPart, ComparisonItem comparisonItem) - { - if (dataPart == null) - { - return false; - } - - var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); - - if (comparisonItem.FileSystemType == FileSystemTypes.File) - { - return contentIdentity?.Core != null; - } - else - { - return contentIdentity != null; - } - } - - private ContentIdentity? ExtractContentIdentity(DataPart? dataPart, ComparisonItem comparisonItem) - { - if (dataPart == null) - { - return null; - } - - var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); - return contentIdentity; - } + var conditionResults = synchronizationRule.Conditions + .Select(condition => _conditionMatcherFactory.GetMatcher(condition.ComparisonProperty).Matches(condition, comparisonItem)) + .ToList(); - private bool ConditionMatchesSize(AtomicCondition condition, ComparisonItem comparisonItem) - { - var sizeSource = ExtractSize(condition.Source, comparisonItem); - - long? sizeDestination; - if (condition.Destination is { IsVirtual: false }) - { - sizeDestination = ExtractSize(condition.Destination, comparisonItem); - } - else - { - var size = (long)condition.Size!; - var sizeUnitPower = (int)condition.SizeUnit! - 1; - - sizeDestination = size * (long)Math.Pow(1024, sizeUnitPower); - } - - if (sizeSource == null || sizeDestination == null) - { - return false; - } - - var result = false; - switch (condition.ConditionOperator) - { - case ConditionOperatorTypes.Equals: - result = sizeSource == sizeDestination; - break; - case ConditionOperatorTypes.NotEquals: - result = sizeSource != sizeDestination; - break; - case ConditionOperatorTypes.IsSmallerThan: - result = sizeSource < sizeDestination; - break; - case ConditionOperatorTypes.IsBiggerThan: - result = sizeSource > sizeDestination; - break; - } - - return result; - } - - private long? ExtractSize(DataPart dataPart, ComparisonItem comparisonItem) - { - var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); - return contentIdentity?.Core?.Size; - } - - private bool ConditionMatchesDate(AtomicCondition condition, ComparisonItem comparisonItem) - { - var lastWriteTimeSource = ExtractDate(condition.Source, comparisonItem); - - DateTime? lastWriteTimeDestination; - if (condition.Destination is { IsVirtual: false }) - { - lastWriteTimeDestination = ExtractDate(condition.Destination, comparisonItem); - } - else - { - lastWriteTimeDestination = condition.DateTime!.Value.ToUniversalTime(); - - if (lastWriteTimeSource is { Second: 0, Millisecond: 0 }) - { - lastWriteTimeSource = lastWriteTimeSource.Value.Trim(TimeSpan.TicksPerMinute); - } - } - - if (lastWriteTimeSource == null) - { - return false; - } - - var result = false; - switch (condition.ConditionOperator) - { - case ConditionOperatorTypes.Equals: - result = lastWriteTimeDestination != null && lastWriteTimeSource == lastWriteTimeDestination; - break; - case ConditionOperatorTypes.NotEquals: - result = lastWriteTimeDestination != null && lastWriteTimeSource != lastWriteTimeDestination; - break; - case ConditionOperatorTypes.IsNewerThan: - result = (condition.Destination is { IsVirtual: false } && lastWriteTimeDestination == null) || - (lastWriteTimeDestination != null && lastWriteTimeSource > lastWriteTimeDestination); - break; - case ConditionOperatorTypes.IsOlderThan: - result = lastWriteTimeDestination != null && lastWriteTimeSource < lastWriteTimeDestination; - break; - } - - return result; - } - - private DateTime? ExtractDate(DataPart dataPart, ComparisonItem comparisonItem) - { - var contentIdentity = LocalizeContentIdentity(dataPart, comparisonItem); - - if (contentIdentity != null) - { - foreach (var pair in contentIdentity.InventoryPartsByLastWriteTimes) - { - if (pair.Value.Contains(dataPart.GetApplicableInventoryPart())) - { - return pair.Key; - } - } - } - - return null; - } - - private bool ConditionMatchesPresence(AtomicCondition condition, ComparisonItem comparisonItem) - { - bool? result = null; - - if (condition.ConditionOperator.In(ConditionOperatorTypes.ExistsOn, ConditionOperatorTypes.NotExistsOn)) - { - var existsOnSource = ExistsOn(condition.Source, comparisonItem); - var existsOnDestination = ExistsOn(condition.Destination, comparisonItem); - - switch (condition.ConditionOperator) - { - case ConditionOperatorTypes.ExistsOn: - result = existsOnSource && existsOnDestination; - break; - case ConditionOperatorTypes.NotExistsOn: - result = existsOnSource && !existsOnDestination; - break; - } - } - - if (result == null) - { - throw new ArgumentOutOfRangeException("ConditionMatchesPresence " + condition.ConditionOperator); - } - - return result.Value; - } - - private bool ConditionMatchesName(AtomicCondition condition, ComparisonItem comparisonItem) - { - if (string.IsNullOrWhiteSpace(condition.NamePattern)) - { - return false; - } - - var name = comparisonItem.PathIdentity.FileName; - var pattern = condition.NamePattern!; - - bool result = false; - - if (pattern.Contains("*") && - condition.ConditionOperator.In(ConditionOperatorTypes.Equals, ConditionOperatorTypes.NotEquals)) + if (synchronizationRule.ConditionMode == ConditionModes.All) { - var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$"; - var safeRegex = new Regex(regex,RegexOptions.IgnoreCase,TimeSpan.FromMilliseconds(500)); - var isMatch = safeRegex.IsMatch(name); - result = condition.ConditionOperator == ConditionOperatorTypes.Equals ? isMatch : !isMatch; + return conditionResults.All(r => r); } else { - switch (condition.ConditionOperator) - { - case ConditionOperatorTypes.Equals: - result = string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase); - break; - case ConditionOperatorTypes.NotEquals: - result = !string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase); - break; - } - } - - return result; - } - - private ContentIdentity? LocalizeContentIdentity(DataPart dataPart, ComparisonItem comparisonItem) - { - if (dataPart.Inventory != null) - { - foreach (var contentIdentity in comparisonItem.ContentIdentities) - { - if (contentIdentity.GetInventories().Contains(dataPart.Inventory)) - { - return contentIdentity; - } - } - } - else if (dataPart.InventoryPart != null) - { - foreach (var contentIdentity in comparisonItem.ContentIdentities) - { - var inventoryParts = contentIdentity.GetInventoryParts(); - - if (inventoryParts.Contains(dataPart.InventoryPart)) - { - return contentIdentity; - } - } + return conditionResults.Any(r => r); } - - return null; } } \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs new file mode 100644 index 000000000..bd3281e49 --- /dev/null +++ b/src/ByteSync.Client/Services/Inventories/FileSystemInspector.cs @@ -0,0 +1,46 @@ +using System.IO; +using ByteSync.Common.Business.Misc; +using ByteSync.Interfaces.Controls.Inventories; + +namespace ByteSync.Services.Inventories; + +public class FileSystemInspector : IFileSystemInspector +{ + private const int FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 4194304; + + public bool IsHidden(FileSystemInfo fsi, OSPlatforms os) + { + var isHidden = (fsi.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden; + var isDot = os == OSPlatforms.Linux && fsi.Name.StartsWith('.'); + + return isHidden || isDot; + } + + public bool IsSystem(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; + } + + public bool IsReparsePoint(FileSystemInfo fsi) + { + return (fsi.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + } + + public bool Exists(FileInfo fileInfo) + { + return fileInfo.Exists; + } + + public bool IsOffline(FileInfo fileInfo) + { + return (fileInfo.Attributes & FileAttributes.Offline) == FileAttributes.Offline; + } + + 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/FilesIdentifier.cs b/src/ByteSync.Client/Services/Inventories/FilesIdentifier.cs index 17300b45f..51318d992 100644 --- a/src/ByteSync.Client/Services/Inventories/FilesIdentifier.cs +++ b/src/ByteSync.Client/Services/Inventories/FilesIdentifier.cs @@ -66,9 +66,13 @@ public HashSet Identify(ComparisonResult comparisonResult) var result = new HashSet(); foreach (var comparisonItem in comparisonItemsToAnalyse) { - var items = InventoryIndexer.GetItemsBy(comparisonItem.PathIdentity)!; + var items = InventoryIndexer.GetItemsBy(comparisonItem.PathIdentity); - result.AddAll(items); + // Items may be null for virtual FileDescriptions (e.g., inaccessible files under inaccessible directories) + if (items != null) + { + result.AddAll(items); + } } return result; diff --git a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index 7a3e0cefa..4c25037e3 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -20,14 +20,13 @@ public class InventoryBuilder : IInventoryBuilder { private readonly ILogger _logger; - private const int FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 4194304; - public InventoryBuilder(SessionMember sessionMember, DataNode dataNode, SessionSettings sessionSettings, InventoryProcessData inventoryProcessData, OSPlatforms osPlatform, FingerprintModes fingerprintMode, ILogger logger, IInventoryFileAnalyzer inventoryFileAnalyzer, IInventorySaver inventorySaver, - IInventoryIndexer inventoryIndexer) + IInventoryIndexer inventoryIndexer, + IFileSystemInspector? fileSystemInspector = null) { _logger = logger; @@ -45,6 +44,7 @@ public InventoryBuilder(SessionMember sessionMember, DataNode dataNode, SessionS InventorySaver = inventorySaver; InventoryFileAnalyzer = inventoryFileAnalyzer; + FileSystemInspector = fileSystemInspector ?? new FileSystemInspector(); } private Inventory InstantiateInventory() @@ -85,6 +85,8 @@ private Inventory InstantiateInventory() private OSPlatforms OSPlatform { get; set; } + private IFileSystemInspector FileSystemInspector { get; } + private bool IgnoreHidden { get { return SessionSettings is { ExcludeHiddenFiles: true }; } @@ -240,52 +242,51 @@ internal void RunAnalysis(string inventoryFullName, HashSet items, } } - private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, CancellationToken cancellationToken) + private void ProcessSubDirectories(InventoryPart inventoryPart, DirectoryInfo directoryInfo, CancellationToken cancellationToken) { - #if DEBUG - if (DebugArguments.ForceSlow) - { - DebugUtils.DebugSleep(0.1d); - } - #endif - - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (ShouldIgnoreHiddenDirectory(directoryInfo)) - { - return; - } - - var directoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, directoryInfo); - - AddFileSystemDescription(inventoryPart, directoryDescription); - - InventoryIndexer.Register(directoryDescription, directoryInfo); - - foreach (var subDirectory in directoryInfo.GetDirectories()) + foreach (var subDirectory in directoryInfo.EnumerateDirectories()) { if (cancellationToken.IsCancellationRequested) { break; } - // https://stackoverflow.com/questions/1485155/check-if-a-file-is-real-or-a-symbolic-link - // Example to create a symlink : - // - Windows: New-Item -ItemType SymbolicLink -Path \path\to\symlink -Target \path\to\target - if (subDirectory.Attributes.HasFlag(FileAttributes.ReparsePoint)) + try + { + if (IsReparsePoint(subDirectory)) + { + continue; + } + } + catch (UnauthorizedAccessException ex) { - _logger.LogWarning("Directory {Directory} is ignored because it has flag 'ReparsePoint'", subDirectory.FullName); + AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, ex, + "Directory {Directory} is inaccessible and will be skipped"); + + continue; + } + catch (DirectoryNotFoundException ex) + { + AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, ex, + "Directory {Directory} not found during enumeration and will be skipped"); + + continue; + } + catch (IOException ex) + { + AddInaccessibleDirectoryAndLog(inventoryPart, subDirectory, ex, + "Directory {Directory} IO error and will be skipped"); continue; } DoAnalyze(inventoryPart, subDirectory, cancellationToken); } - - foreach (var subFile in directoryInfo.GetFiles()) + } + + private void ProcessFiles(InventoryPart inventoryPart, DirectoryInfo directoryInfo, CancellationToken cancellationToken) + { + foreach (var subFile in directoryInfo.EnumerateFiles()) { if (cancellationToken.IsCancellationRequested) { @@ -296,6 +297,14 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, } } + private void AddInaccessibleDirectoryAndLog(InventoryPart inventoryPart, DirectoryInfo directoryInfo, Exception ex, string message) + { + var subDirectoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, directoryInfo); + subDirectoryDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, subDirectoryDescription); + _logger.LogWarning(ex, message, directoryInfo.FullName); + } + private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) { if (!IgnoreHidden) @@ -303,8 +312,7 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) return false; } - if (directoryInfo.Attributes.HasFlag(FileAttributes.Hidden) || - (OSPlatform == OSPlatforms.Linux && directoryInfo.Name.StartsWith("."))) + if (FileSystemInspector.IsHidden(directoryInfo, OSPlatform)) { _logger.LogInformation("Directory {Directory} is ignored because considered as hidden", directoryInfo.FullName); @@ -328,63 +336,193 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) return; } - if (IgnoreHidden) + try { - if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden) || - (OSPlatform == OSPlatforms.Linux && fileInfo.Name.StartsWith("."))) + if (ShouldIgnoreHiddenFile(fileInfo)) { - _logger.LogInformation("File {File} is ignored because considered as hidden", fileInfo.FullName); - return; } - } - - - if (IgnoreSystem) - { - if (fileInfo.Name.In("desktop.ini", "thumbs.db", ".desktop.ini", ".thumbs.db", ".DS_Store") - || fileInfo.Attributes.HasFlag(FileAttributes.System)) + + if (ShouldIgnoreSystemFile(fileInfo)) + { + return; + } + + if (IsReparsePoint(fileInfo)) { - _logger.LogInformation("File {File} is ignored because considered as system", fileInfo.FullName); - return; } + + if (!FileSystemInspector.Exists(fileInfo) || FileSystemInspector.IsOffline(fileInfo) || IsRecallOnDataAccess(fileInfo)) + { + return; + } + + var fileDescription = IdentityBuilder.BuildFileDescription(inventoryPart, fileInfo); + + AddFileSystemDescription(inventoryPart, fileDescription); + + InventoryIndexer.Register(fileDescription, fileInfo); + } + catch (UnauthorizedAccessException ex) + { + AddInaccessibleFileAndLog(inventoryPart, fileInfo, ex, + "File {File} is inaccessible and will be skipped"); + } + catch (DirectoryNotFoundException ex) + { + AddInaccessibleFileAndLog(inventoryPart, fileInfo, ex, + "File {File} parent directory not found and will be skipped"); + } + catch (IOException ex) + { + AddInaccessibleFileAndLog(inventoryPart, fileInfo, ex, + "File {File} IO error and will be skipped"); + } + } + + + private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, CancellationToken cancellationToken) + { + #if DEBUG + if (DebugArguments.ForceSlow) + { + DebugUtils.DebugSleep(0.1d); } + #endif - // https://stackoverflow.com/questions/1485155/check-if-a-file-is-real-or-a-symbolic-link - // Example to create a symlink : - // - Windows: New-Item -ItemType SymbolicLink -Path \path\to\symlink -Target \path\to\target - if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + if (cancellationToken.IsCancellationRequested) { - _logger.LogWarning("File {File} is ignored because it has flag 'ReparsePoint'. It might be a symbolic link", fileInfo.FullName); - return; } - if (!fileInfo.Exists) + if (ShouldIgnoreHiddenDirectory(directoryInfo)) { return; } - if (fileInfo.Attributes.HasFlag(FileAttributes.Offline)) + var directoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, directoryInfo); + + AddFileSystemDescription(inventoryPart, directoryDescription); + + InventoryIndexer.Register(directoryDescription, directoryInfo); + + try { - return; + ProcessSubDirectories(inventoryPart, directoryInfo, cancellationToken); + ProcessFiles(inventoryPart, directoryInfo, cancellationToken); + } + catch (UnauthorizedAccessException ex) + { + directoryDescription.IsAccessible = false; + _logger.LogWarning(ex, "Directory {Directory} is inaccessible and will be skipped", directoryInfo.FullName); + } + catch (DirectoryNotFoundException ex) + { + directoryDescription.IsAccessible = false; + _logger.LogWarning(ex, "Directory {Directory} not found during enumeration and will be skipped", directoryInfo.FullName); + } + catch (IOException ex) + { + directoryDescription.IsAccessible = false; + _logger.LogWarning(ex, "Directory {Directory} IO error and will be skipped", directoryInfo.FullName); + } + } + + private bool ShouldIgnoreHiddenFile(FileInfo fileInfo) + { + if (!IgnoreHidden) + { + return false; } - // Non-Local OneDrive Files (not GoogleDrive) - // https://docs.microsoft.com/en-gb/windows/win32/fileio/file-attribute-constants?redirectedfrom=MSDN - // https://stackoverflow.com/questions/49301958/how-to-detect-onedrive-online-only-files - // https://stackoverflow.com/questions/54560454/getting-full-file-attributes-for-files-managed-by-microsoft-onedrive - if (((int)fileInfo.Attributes & FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) == FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) + if (FileSystemInspector.IsHidden(fileInfo, OSPlatform)) { - return; + _logger.LogInformation("File {File} is ignored because considered as hidden", fileInfo.FullName); + + return true; + } + + return false; + } + + private bool ShouldIgnoreSystemFile(FileInfo fileInfo) + { + if (!IgnoreSystem) + { + return false; + } + + if (FileSystemInspector.IsSystem(fileInfo)) + { + _logger.LogInformation("File {File} is ignored because considered as system", fileInfo.FullName); + + return true; } - var fileDescription = IdentityBuilder.BuildFileDescription(inventoryPart, fileInfo); + return false; + } + + 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); + } + + private void AddInaccessibleFileAndLog(InventoryPart inventoryPart, FileInfo fileInfo, Exception ex, string message) + { + var relativePath = BuildRelativePath(inventoryPart, fileInfo); + var fileDescription = new FileDescription(inventoryPart, relativePath) + { + IsAccessible = false + }; AddFileSystemDescription(inventoryPart, fileDescription); + _logger.LogWarning(ex, message, fileInfo.FullName); + } + + private string BuildRelativePath(InventoryPart inventoryPart, FileInfo fileInfo) + { + if (inventoryPart.InventoryPartType != FileSystemTypes.Directory) + { + return "/" + fileInfo.Name; + } + + var rawRelativePath = IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + var normalizedPath = OSPlatform == OSPlatforms.Windows + ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) + : rawRelativePath; - InventoryIndexer.Register(fileDescription, fileInfo); + if (!normalizedPath.StartsWith(IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR)) + { + normalizedPath = IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR + normalizedPath; + } + + return normalizedPath; } private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDescription fileSystemDescription) @@ -398,7 +536,11 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes { InventoryProcessData.UpdateMonitorData(imd => { - imd.IdentifiedVolume += fileDescription.Size; + if (fileDescription.IsAccessible) + { + imd.IdentifiedVolume += fileDescription.Size; + } + imd.IdentifiedFiles += 1; }); } diff --git a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs index 93584d4d2..8d954a1bf 100644 --- a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs +++ b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs @@ -1,7 +1,9 @@ using System.Collections.ObjectModel; +using ByteSync.Assets.Resources; using ByteSync.Business.Sessions; using ByteSync.Common.Business.Inventories; using ByteSync.Interfaces.Factories.ViewModels; +using ByteSync.Interfaces.Services.Localizations; using ByteSync.Interfaces.Services.Sessions; using ByteSync.Models.Comparisons.Result; using ByteSync.Models.FileSystems; @@ -15,6 +17,7 @@ namespace ByteSync.ViewModels.Sessions.Comparisons.Results; public class ContentIdentityViewModel : ViewModelBase { private readonly ISessionService _sessionService; + private readonly ILocalizationService _localizationService; private readonly IDateAndInventoryPartsViewModelFactory _dateAndInventoryPartsViewModelFactory; public ContentIdentityViewModel() @@ -35,6 +38,7 @@ public ContentIdentityViewModel() public ContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, ContentIdentity contentIdentity, Inventory inventory, ISessionService sessionService, + ILocalizationService localizationService, IDateAndInventoryPartsViewModelFactory dateAndInventoryPartsViewModelFactory) { ComparisonItemViewModel = comparisonItemViewModel; @@ -45,6 +49,7 @@ public ContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, IsDirectory = !IsFile; _sessionService = sessionService; + _localizationService = localizationService; _dateAndInventoryPartsViewModelFactory = dateAndInventoryPartsViewModelFactory; DateAndInventoryParts = new ObservableCollection(); @@ -56,10 +61,15 @@ public ContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, ShowInventoryParts = _sessionService.IsCloudSession; HasAnalysisError = ContentIdentity.HasAnalysisError; + HasAccessIssue = ContentIdentity.HasAccessIssueFor(Inventory) || ContentIdentity.HasAccessIssue; if (HasAnalysisError) { ShowToolTipDelay = 400; } + else if (HasAccessIssue) + { + ShowToolTipDelay = 400; + } else if (LinkingKeyNameTooltip.IsNotEmpty()) { ShowToolTipDelay = 400; @@ -91,6 +101,9 @@ public ContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, [Reactive] public bool HasAnalysisError { get; set; } + [Reactive] + public bool HasAccessIssue { get; set; } + [Reactive] public int ShowToolTipDelay { get; set; } @@ -126,13 +139,17 @@ private void FillStringData() .First(fsd => fsd is FileDescription { HasAnalysisError: true }) as FileDescription; - SignatureHash = onErrorFileDescription!.AnalysisErrorType.Truncate(32); + SignatureHash = onErrorFileDescription!.AnalysisErrorType!.Truncate(32); ErrorType = onErrorFileDescription.AnalysisErrorType; ErrorDescription = onErrorFileDescription.AnalysisErrorDescription; } else { - if (ContentIdentity.Core == null || ContentIdentity.Core.SignatureHash.IsNullOrEmpty()) + if (ContentIdentity.HasAccessIssueFor(Inventory)) + { + SignatureHash = _localizationService[nameof(Resources.ContentIdentity_AccessIssueShortLabel)]; + } + else if (ContentIdentity.Core == null || ContentIdentity.Core.SignatureHash.IsNullOrEmpty()) { SignatureHash = ""; } @@ -166,7 +183,8 @@ private void FillStringData() LinkingKeyNameTooltip = ComparisonItemViewModel.LinkingKeyNameTooltip; } - if (IsDirectory) + // Show inventory parts (B1, B2, etc.) for directories OR for inaccessible files + if (IsDirectory || (IsFile && ContentIdentity.HasAccessIssueFor(Inventory))) { PresenceParts = ContentIdentity.GetInventoryParts() .Where(ip => ip.Inventory.Equals(Inventory)) @@ -179,7 +197,7 @@ private void FillStringData() private void SetHashOrWarnIcon() { - if (ContentIdentity.HasAnalysisError) + if (ContentIdentity.HasAnalysisError || ContentIdentity.HasAccessIssueFor(Inventory)) { HashOrWarnIcon = "RegularError"; } @@ -193,6 +211,12 @@ private void FillDateAndInventoryParts() { DateAndInventoryParts.Clear(); + // Don't show dates for inaccessible files + if (ContentIdentity.HasAccessIssueFor(Inventory)) + { + return; + } + foreach (var pair in ContentIdentity.InventoryPartsByLastWriteTimes) { var inventoryPartsOK = diff --git a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModel.cs index 6d2dd197b..ea4a7992e 100644 --- a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModel.cs +++ b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModel.cs @@ -4,7 +4,6 @@ using System.Text; using ByteSync.Business.Inventories; using ByteSync.Common.Business.Inventories; -using ByteSync.Interfaces; using ByteSync.Interfaces.Controls.Comparisons; using ByteSync.Interfaces.Converters; using ByteSync.Interfaces.Factories.ViewModels; @@ -29,11 +28,12 @@ public class ComparisonItemViewModel : IDisposable private readonly ISynchronizationActionViewModelFactory _synchronizationActionViewModelFactory; private readonly CompositeDisposable _compositeDisposable; private readonly IFormatKbSizeConverter _formatKbSizeConverter; - + public ComparisonItemViewModel(ITargetedActionsService targetedActionsService, IAtomicActionRepository atomicActionRepository, IContentIdentityViewModelFactory contentIdentityViewModelFactory, - IContentRepartitionViewModelFactory contentRepartitionViewModelFactory, - IItemSynchronizationStatusViewModelFactory itemSynchronizationStatusViewModelFactory, ComparisonItem comparisonItem, List inventories, + IContentRepartitionViewModelFactory contentRepartitionViewModelFactory, + IItemSynchronizationStatusViewModelFactory itemSynchronizationStatusViewModelFactory, ComparisonItem comparisonItem, + List inventories, ISynchronizationActionViewModelFactory synchronizationActionViewModelFactory, IFormatKbSizeConverter formatKbSizeConverter) { ComparisonItem = comparisonItem; @@ -46,13 +46,13 @@ public ComparisonItemViewModel(ITargetedActionsService targetedActionsService, _itemSynchronizationStatusViewModelFactory = itemSynchronizationStatusViewModelFactory; _synchronizationActionViewModelFactory = synchronizationActionViewModelFactory; _formatKbSizeConverter = formatKbSizeConverter; - - ContentIdentitiesA = new HashSet(); - ContentIdentitiesB = new HashSet(); - ContentIdentitiesC = new HashSet(); - ContentIdentitiesD = new HashSet(); - ContentIdentitiesE = new HashSet(); - + + ContentIdentitiesA = new List(); + ContentIdentitiesB = new List(); + ContentIdentitiesC = new List(); + ContentIdentitiesD = new List(); + ContentIdentitiesE = new List(); + ContentIdentitiesList = [ ContentIdentitiesA, @@ -61,9 +61,9 @@ public ComparisonItemViewModel(ITargetedActionsService targetedActionsService, ContentIdentitiesD, ContentIdentitiesE ]; - + _compositeDisposable = new CompositeDisposable(); - + _atomicActionRepository.ObservableCache.Connect() .Filter(sa => sa.PathIdentity != null && Equals(sa.PathIdentity, ComparisonItem.PathIdentity)) .Transform(sa => _synchronizationActionViewModelFactory.CreateSynchronizationActionViewModel(sa, this)) @@ -72,50 +72,68 @@ public ComparisonItemViewModel(ITargetedActionsService targetedActionsService, .Bind(out _data) .Subscribe() .DisposeWith(_compositeDisposable); - + PathIdentity = comparisonItem.PathIdentity; - + BuildLinkingKeyNameTooltip(); + // Create all ContentIdentityViewModels foreach (var contentIdentity in ComparisonItem.ContentIdentities) { foreach (var inventory in contentIdentity.GetInventories()) { var collection = GetContentIdentityViews(inventory); - + var contentIdentityView = _contentIdentityViewModelFactory.CreateContentIdentityViewModel(this, contentIdentity, inventory); collection.Add(contentIdentityView); } } - + + // Sort each collection by inventory part codes (A1, A2, B1, B2, etc.) + foreach (var contentIdentitiesCollection in ContentIdentitiesList) + { + contentIdentitiesCollection.Sort((a, b) => + { + var aMinCode = a.ContentIdentity.GetInventoryParts() + .Where(ip => ip.Inventory.Equals(a.Inventory)) + .Min(ip => ip.Code); + var bMinCode = b.ContentIdentity.GetInventoryParts() + .Where(ip => ip.Inventory.Equals(b.Inventory)) + .Min(ip => ip.Code); + + return string.Compare(aMinCode, bMinCode, StringComparison.Ordinal); + }); + } + ContentRepartitionViewModel = _contentRepartitionViewModelFactory.CreateContentRepartitionViewModel(ComparisonItem, inventories); _compositeDisposable.Add(ContentRepartitionViewModel); - ItemSynchronizationStatusViewModel = _itemSynchronizationStatusViewModelFactory.CreateItemSynchronizationStatusViewModel(ComparisonItem, inventories); + ItemSynchronizationStatusViewModel = + _itemSynchronizationStatusViewModelFactory.CreateItemSynchronizationStatusViewModel(ComparisonItem, inventories); _compositeDisposable.Add(ItemSynchronizationStatusViewModel); } - + internal ComparisonItem ComparisonItem { get; } - + internal List Inventories { get; } - + internal PathIdentity PathIdentity { get; } - - internal HashSet ContentIdentitiesA { get; } - - internal HashSet ContentIdentitiesB { get; } - - internal HashSet ContentIdentitiesC { get; } - - internal HashSet ContentIdentitiesD { get; } - - internal HashSet ContentIdentitiesE { get; } - - internal List> ContentIdentitiesList { get; set; } + + public List ContentIdentitiesA { get; } + + public List ContentIdentitiesB { get; } + + public List ContentIdentitiesC { get; } + + public List ContentIdentitiesD { get; } + + public List ContentIdentitiesE { get; } + + public List> ContentIdentitiesList { get; set; } public ReadOnlyObservableCollection SynchronizationActions => _data; - + internal ContentRepartitionViewModel ContentRepartitionViewModel { get; private set; } internal ItemSynchronizationStatusViewModel ItemSynchronizationStatusViewModel { get; private set; } @@ -123,16 +141,13 @@ public ComparisonItemViewModel(ITargetedActionsService targetedActionsService, private List? AtomicActionsIds { get; set; } internal string? LinkingKeyNameTooltip { get; set; } - + public FileSystemTypes FileSystemType { - get - { - return PathIdentity.FileSystemType; - } + get { return PathIdentity.FileSystemType; } } - - internal HashSet GetContentIdentityViews(Inventory inventory) + + public List GetContentIdentityViews(Inventory inventory) { var index = Inventories.IndexOf(inventory); @@ -149,11 +164,13 @@ internal HashSet GetContentIdentityViews(Inventory inv case 4: return ContentIdentitiesE; default: - Log.Error("GetContentIdentityViews: can not identify ContentIdentities for index:{Index}, inventory:{Inventory}", index, inventory.InventoryId); + Log.Error("GetContentIdentityViews: can not identify ContentIdentities for index:{Index}, inventory:{Inventory}", index, + inventory.InventoryId); + throw new ApplicationException($"GetContentIdentityViews: can not identify ContentIdentities, {index}:"); } } - + public void ClearTargetedActions() { _targetedActionsService.ClearTargetedActions(this); @@ -167,9 +184,14 @@ private void BuildLinkingKeyNameTooltip() } var linkingKeyNameTooltip = new StringBuilder(); - + + // Sort ContentIdentities by inventory parts codes (A1, A2, B1, B2, etc.) + var sortedContentIdentities = ComparisonItem.ContentIdentities + .OrderBy(ci => ci.GetInventoryParts().Min(ip => ip.Code)) + .ToList(); + var isFirst = true; - foreach (var contentIdentity in ComparisonItem.ContentIdentities) + foreach (var contentIdentity in sortedContentIdentities) { if (isFirst) { @@ -181,32 +203,40 @@ private void BuildLinkingKeyNameTooltip() } linkingKeyNameTooltip.AppendLine("___________________________________________________________"); - if (contentIdentity.Core!.SignatureHash.IsNotEmpty()) + + // Handle virtual ContentIdentity (inaccessible files with null Core) + if (contentIdentity.Core == null) { - linkingKeyNameTooltip.AppendLine(contentIdentity.Core.SignatureHash); + linkingKeyNameTooltip.AppendLine("Inaccessible"); + linkingKeyNameTooltip.AppendLine("‾‾‾‾‾‾‾‾‾‾‾‾"); + } + else if (contentIdentity.Core.SignatureHash.IsNotEmpty()) + { + linkingKeyNameTooltip.AppendLine(contentIdentity.Core.SignatureHash); - linkingKeyNameTooltip.AppendLine(" ‾‾‾"); // Overline U+203E https://en.wikipedia.org/wiki/Overline + linkingKeyNameTooltip.AppendLine( + " ‾‾‾"); // Overline U+203E https://en.wikipedia.org/wiki/Overline } else { var size = _formatKbSizeConverter.Convert(contentIdentity.Core.Size); - linkingKeyNameTooltip.AppendLine(size); + linkingKeyNameTooltip.AppendLine(size); linkingKeyNameTooltip.AppendLine("‾‾‾‾‾‾"); // Overline U+203E https://en.wikipedia.org/wiki/Overline } - - + var inventoryParts = contentIdentity.GetInventoryParts().OrderBy(ip => ip.Code); foreach (var inventoryPart in inventoryParts) { var fileDescriptions = contentIdentity.GetFileSystemDescriptions(inventoryPart); - + if (fileDescriptions.Count > 0) { - linkingKeyNameTooltip.AppendLine(inventoryPart.Inventory.MachineName + " " + inventoryPart.Code + " (" + inventoryPart.RootPath + ")"); - + linkingKeyNameTooltip.AppendLine(inventoryPart.Inventory.MachineName + " " + inventoryPart.Code + " (" + + inventoryPart.RootPath + ")"); + foreach (var fileDescription in fileDescriptions) { linkingKeyNameTooltip.AppendLine(" - " + fileDescription.RelativePath); @@ -216,19 +246,19 @@ private void BuildLinkingKeyNameTooltip() } } } - + linkingKeyNameTooltip.TrimEnd(); - + LinkingKeyNameTooltip = linkingKeyNameTooltip.ToString(); } - + public void Dispose() { _compositeDisposable.Dispose(); - + AtomicActionsIds = null; } - + public void OnLocaleChanged(ILocalizationService localizationService) { foreach (var contentIdentityViewModel in ContentIdentitiesA) diff --git a/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml b/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml index 902f1076b..24ba445fd 100644 --- a/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml +++ b/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml @@ -7,15 +7,15 @@ xmlns:localizations="clr-namespace:ByteSync.Services.Localizations" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ByteSync.Views.Sessions.Comparisons.Results.ContentIdentityView"> - + - + + ToolTip.ShowDelay="{Binding ShowToolTipDelay}"> @@ -24,25 +24,27 @@ - + - + Margin="3 0 0 0" VerticalAlignment="Center" /> + - - + + - + Margin="15 0 0 0" VerticalAlignment="Center" /> + - + @@ -51,57 +53,80 @@ - - + Margin="3 0 0 0" VerticalAlignment="Center" /> + + - - - + + + + - + + + + + + + + + + + - + - - - + Margin="15 0 0 0" VerticalAlignment="Center" /> + + + + - + - - + + - - + + - - + + - - + + + + + + - + - + - + \ No newline at end of file diff --git a/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs b/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs new file mode 100644 index 000000000..9df2c7b59 --- /dev/null +++ b/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs @@ -0,0 +1,173 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using Autofac; +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Sessions; +using ByteSync.Client.IntegrationTests.TestHelpers; +using ByteSync.Client.IntegrationTests.TestHelpers.Business; +using ByteSync.Common.Business.Actions; +using ByteSync.Common.Business.Inventories; +using ByteSync.Common.Helpers; +using ByteSync.Interfaces; +using ByteSync.Interfaces.Controls.Applications; +using ByteSync.Interfaces.Repositories; +using ByteSync.Interfaces.Services.Sessions; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Sessions; +using ByteSync.TestsCommon; +using FluentAssertions; +using Moq; + +namespace ByteSync.Client.IntegrationTests.Services.Comparisons; + +public class TargetInaccessible_IntegrationTests : IntegrationTest +{ + private ComparisonResultPreparer _comparisonResultPreparer = null!; + private AtomicActionConsistencyChecker _checker = null!; + + [SetUp] + public void Setup() + { + RegisterType(); + RegisterType(); + RegisterType(); + BuildMoqContainer(); + + var contextHelper = new TestContextGenerator(Container); + contextHelper.GenerateSession(); + contextHelper.GenerateCurrentEndpoint(); + var testDirectory = _testDirectoryService.CreateTestDirectory(); + + var env = Container.Resolve>(); + env.Setup(m => m.AssemblyFullName).Returns(IOUtils.Combine(testDirectory.FullName, "Assembly", "Assembly.exe")); + + var appData = Container.Resolve>(); + appData.Setup(m => m.ApplicationDataPath).Returns(IOUtils.Combine(testDirectory.FullName, "ApplicationDataPath")); + + // Ensure repository returns an empty set of existing actions + Container.Resolve>() + .Setup(r => r.GetAtomicActions(It.IsAny())) + .Returns([]); + + _comparisonResultPreparer = Container.Resolve(); + _checker = Container.Resolve(); + } + + [Test] + [Platform(Include = "Win")] +#pragma warning disable CA1416 + public async Task Synchronize_Fails_When_Target_File_Inaccessible_Windows() + { + var dataA = _testDirectoryService.CreateSubTestDirectory("dataA"); + var dataB = _testDirectoryService.CreateSubTestDirectory("dataB"); + + _ = _testDirectoryService.CreateFileInDirectory(dataA, "file.txt", "source"); + var fileB = _testDirectoryService.CreateFileInDirectory(dataB, "file.txt", "target"); + + // Deny read access on target file for current user, then restore + + _ = fileB.GetAccessControl(); + var sid = WindowsIdentity.GetCurrent().User!; + var denyRule = new FileSystemAccessRule(sid, + FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes, + AccessControlType.Deny); + + try + { + var sec = fileB.GetAccessControl(); + sec.AddAccessRule(denyRule); + fileB.SetAccessControl(sec); + + var settings = SessionSettingsGenerator.GenerateSessionSettings(DataTypes.Files, MatchingModes.Tree, AnalysisModes.Smart); + var invA = new InventoryData(dataA); + var invB = new InventoryData(dataB); + var comparisonResult = await _comparisonResultPreparer.BuildAndCompare(settings, invA, invB); + + var targetItem = comparisonResult.ComparisonItems + .Single(ci => ci.FileSystemType == FileSystemTypes.File && ci.PathIdentity.FileName == "file.txt"); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentOnly, + Source = invA.GetSingleDataPart(), + Destination = invB.GetSingleDataPart(), + ComparisonItem = targetItem + }; + + var result = _checker.CheckCanAdd(action, targetItem); + result.IsOK.Should().BeFalse(); + result.ValidationResults.Should().ContainSingle(); + result.ValidationResults[0].IsValid.Should().BeFalse(); + var reason = result.ValidationResults[0].FailureReason!.Value; + reason.Should().BeOneOf( + AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible, + AtomicActionValidationFailureReason.AtLeastOneTargetsHasAnalysisError, + AtomicActionValidationFailureReason.NothingToCopyContentAndDateIdentical); + } + finally + { + // Restore permissions to allow deletion + var sec = fileB.GetAccessControl(); + sec.RemoveAccessRule(denyRule); + fileB.SetAccessControl(sec); + } + } +#pragma warning restore CA1416 + + [Test] + [Platform(Include = "Linux,MacOsX")] + public async Task Synchronize_Fails_When_Target_File_Inaccessible_Posix() + { + var dataA = _testDirectoryService.CreateSubTestDirectory("dataA"); + var dataB = _testDirectoryService.CreateSubTestDirectory("dataB"); + + _ = _testDirectoryService.CreateFileInDirectory(dataA, "file.txt", "source"); + var fileB = _testDirectoryService.CreateFileInDirectory(dataB, "file.txt", "target"); + + // Make file unreadable: chmod 000, then restore to 0644 + var path = fileB.FullName; + try + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(path, UnixFileMode.None); + } + + var settings = SessionSettingsGenerator.GenerateSessionSettings(DataTypes.Files, MatchingModes.Tree, AnalysisModes.Smart); + var invA = new InventoryData(dataA); + var invB = new InventoryData(dataB); + var comparisonResult = await _comparisonResultPreparer.BuildAndCompare(settings, invA, invB); + + var targetItem = comparisonResult.ComparisonItems + .Single(ci => ci.FileSystemType == FileSystemTypes.File && ci.PathIdentity.FileName == "file.txt"); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentOnly, + Source = invA.GetSingleDataPart(), + Destination = invB.GetSingleDataPart(), + ComparisonItem = targetItem + }; + + var result = _checker.CheckCanAdd(action, targetItem); + result.IsOK.Should().BeFalse(); + result.ValidationResults.Should().ContainSingle(); + result.ValidationResults[0].IsValid.Should().BeFalse(); + var reason = result.ValidationResults[0].FailureReason!.Value; + reason.Should().BeOneOf( + AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible, + AtomicActionValidationFailureReason.AtLeastOneTargetsHasAnalysisError, + AtomicActionValidationFailureReason.NothingToCopyContentAndDateIdentical); + } + finally + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + } + } + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.IntegrationTests/Services/Inventories/InventoryBuilderAccessHandling_IntegrationTests.cs b/tests/ByteSync.Client.IntegrationTests/Services/Inventories/InventoryBuilderAccessHandling_IntegrationTests.cs new file mode 100644 index 000000000..7c47f434f --- /dev/null +++ b/tests/ByteSync.Client.IntegrationTests/Services/Inventories/InventoryBuilderAccessHandling_IntegrationTests.cs @@ -0,0 +1,840 @@ +using System.Reactive.Linq; +using System.Security.AccessControl; +using System.Security.Principal; +using Autofac; +using ByteSync.Business; +using ByteSync.Business.DataNodes; +using ByteSync.Business.Inventories; +using ByteSync.Business.SessionMembers; +using ByteSync.Business.Sessions; +using ByteSync.Client.IntegrationTests.TestHelpers; +using ByteSync.Common.Business.Inventories; +using ByteSync.Common.Business.Misc; +using ByteSync.Interfaces; +using ByteSync.Interfaces.Controls.Applications; +using ByteSync.Services.Inventories; +using ByteSync.TestsCommon; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ByteSync.Client.IntegrationTests.Services.Inventories; + +#pragma warning disable CA1416 +[TestFixture] +public class InventoryBuilderAccessHandling_IntegrationTests : IntegrationTest +{ + private InventoryProcessData _inventoryProcessData = null!; + + [SetUp] + public void Setup() + { + BuildMoqContainer(); + + var contextHelper = new TestContextGenerator(Container); + contextHelper.GenerateSession(); + contextHelper.GenerateCurrentEndpoint(); + var testDirectory = _testDirectoryService.CreateTestDirectory(); + + var env = Container.Resolve>(); + env.Setup(m => m.AssemblyFullName).Returns(Path.Combine(testDirectory.FullName, "Assembly", "Assembly.exe")); + + var appData = Container.Resolve>(); + appData.Setup(m => m.ApplicationDataPath).Returns(Path.Combine(testDirectory.FullName, "ApplicationDataPath")); + + _inventoryProcessData = new InventoryProcessData(); + } + + [Test] + [Platform(Include = "Win")] + public async Task InaccessibleDirectory_MarkedAsInaccessibleAndSkipped_Windows() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + var accessibleDir = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "accessible")); + var inaccessibleDir = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "inaccessible")); + + await File.WriteAllTextAsync(Path.Combine(accessibleDir.FullName, "file1.txt"), "content1"); + + var fileInInaccessible = Path.Combine(inaccessibleDir.FullName, "file2.txt"); + await File.WriteAllTextAsync(fileInInaccessible, "content2"); + + + inaccessibleDir.GetAccessControl(); + var sid = WindowsIdentity.GetCurrent().User!; + var denyRule = new FileSystemAccessRule(sid, + FileSystemRights.ListDirectory | FileSystemRights.ReadData, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Deny); + + try + { + var sec = inaccessibleDir.GetAccessControl(); + sec.AddAccessRule(denyRule); + inaccessibleDir.SetAccessControl(sec); + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + OSPlatforms.Windows, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(dataRoot.FullName); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + part.DirectoryDescriptions.Should().HaveCountGreaterThanOrEqualTo(2); + + var accessibleDirDesc = part.DirectoryDescriptions + .FirstOrDefault(d => d.RelativePath.Contains("accessible")); + accessibleDirDesc.Should().NotBeNull(); + accessibleDirDesc.IsAccessible.Should().BeTrue(); + + var inaccessibleDirDesc = part.DirectoryDescriptions + .FirstOrDefault(d => d.RelativePath.Contains("inaccessible")); + inaccessibleDirDesc.Should().NotBeNull(); + + if (inaccessibleDirDesc.IsAccessible) + { + Assert.Ignore( + "Directory permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + inaccessibleDirDesc.IsAccessible.Should().BeFalse(); + + var file1 = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("file1.txt")); + file1.Should().NotBeNull(); + file1.IsAccessible.Should().BeTrue(); + + var file2 = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("file2.txt")); + file2.Should().BeNull("the file in an inaccessible directory should not be inventoried"); + } + finally + { + var sec = inaccessibleDir.GetAccessControl(); + sec.RemoveAccessRule(denyRule); + inaccessibleDir.SetAccessControl(sec); + } + } + + [Test] + [Platform(Include = "Linux,MacOsX")] + public async Task InaccessibleDirectory_MarkedAsInaccessibleAndSkipped_Posix() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + var accessibleDir = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "accessible")); + var inaccessibleDir = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "inaccessible")); + + await File.WriteAllTextAsync(Path.Combine(accessibleDir.FullName, "file1.txt"), "content1"); + var fileInInaccessible = Path.Combine(inaccessibleDir.FullName, "file2.txt"); + await File.WriteAllTextAsync(fileInInaccessible, "content2"); + + try + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(inaccessibleDir.FullName, UnixFileMode.None); + } + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + OSPlatforms.Linux, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(dataRoot.FullName); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + part.DirectoryDescriptions.Should().HaveCountGreaterThanOrEqualTo(2); + + var accessibleDirDesc = part.DirectoryDescriptions + .FirstOrDefault(d => d.RelativePath.Contains("accessible")); + accessibleDirDesc.Should().NotBeNull(); + accessibleDirDesc.IsAccessible.Should().BeTrue(); + + var inaccessibleDirDesc = part.DirectoryDescriptions + .FirstOrDefault(d => d.RelativePath.Contains("inaccessible")); + inaccessibleDirDesc.Should().NotBeNull(); + + if (inaccessibleDirDesc.IsAccessible) + { + Assert.Ignore( + "Directory permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + inaccessibleDirDesc.IsAccessible.Should().BeFalse(); + + var file1 = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("file1.txt")); + file1.Should().NotBeNull(); + file1.IsAccessible.Should().BeTrue(); + + var file2 = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("file2.txt")); + file2.Should().BeNull("the file in an inaccessible directory should not be inventoried"); + } + finally + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(inaccessibleDir.FullName, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + } + + [Test] + [Platform(Include = "Win")] + public async Task InaccessibleFile_MarkedAsInaccessibleButDirectoryAccessible_Windows() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + var subDir = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "subdir")); + + var accessibleFile = Path.Combine(subDir.FullName, "accessible.txt"); + await File.WriteAllTextAsync(accessibleFile, "content1"); + + var inaccessibleFile = Path.Combine(subDir.FullName, "inaccessible.txt"); + await File.WriteAllTextAsync(inaccessibleFile, "content2"); + + new FileInfo(inaccessibleFile).GetAccessControl(); + var sid = WindowsIdentity.GetCurrent().User!; + var denyRule = new FileSystemAccessRule(sid, + FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes, + AccessControlType.Deny); + + try + { + var sec = new FileInfo(inaccessibleFile).GetAccessControl(); + sec.AddAccessRule(denyRule); + new FileInfo(inaccessibleFile).SetAccessControl(sec); + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + OSPlatforms.Windows, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(dataRoot.FullName); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + var subDirDesc = part.DirectoryDescriptions.FirstOrDefault(d => d.RelativePath.Contains("subdir")); + subDirDesc.Should().NotBeNull(); + subDirDesc.IsAccessible.Should().BeTrue(); + + var accessibleFileDesc = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("accessible.txt")); + accessibleFileDesc.Should().NotBeNull(); + accessibleFileDesc.IsAccessible.Should().BeTrue(); + + var inaccessibleFileDesc = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("inaccessible.txt")); + inaccessibleFileDesc.Should().NotBeNull(); + + if (inaccessibleFileDesc.IsAccessible) + { + Assert.Ignore( + "File permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + inaccessibleFileDesc.IsAccessible.Should().BeFalse(); + } + finally + { + var sec = new FileInfo(inaccessibleFile).GetAccessControl(); + sec.RemoveAccessRule(denyRule); + new FileInfo(inaccessibleFile).SetAccessControl(sec); + } + } + + [Test] + [Platform(Include = "Linux,MacOsX")] + public async Task InaccessibleFile_MarkedAsInaccessibleButDirectoryAccessible_Posix() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + var subDir = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "subdir")); + + var accessibleFile = Path.Combine(subDir.FullName, "accessible.txt"); + await File.WriteAllTextAsync(accessibleFile, "content1"); + + var inaccessibleFile = Path.Combine(subDir.FullName, "inaccessible.txt"); + await File.WriteAllTextAsync(inaccessibleFile, "content2"); + + try + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(inaccessibleFile, UnixFileMode.None); + } + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + OSPlatforms.Linux, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(dataRoot.FullName); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + var subDirDesc = part.DirectoryDescriptions.FirstOrDefault(d => d.RelativePath.Contains("subdir")); + subDirDesc.Should().NotBeNull(); + subDirDesc.IsAccessible.Should().BeTrue(); + + var accessibleFileDesc = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("accessible.txt")); + accessibleFileDesc.Should().NotBeNull(); + accessibleFileDesc.IsAccessible.Should().BeTrue(); + + var inaccessibleFileDesc = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("inaccessible.txt")); + inaccessibleFileDesc.Should().NotBeNull(); + + if (inaccessibleFileDesc.IsAccessible) + { + Assert.Ignore( + "File permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + inaccessibleFileDesc.IsAccessible.Should().BeFalse(); + } + finally + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(inaccessibleFile, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + } + } + } + + [Test] + public async Task InaccessibleFiles_NotCountedInIdentifiedVolume() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + + var accessibleFile1 = Path.Combine(dataRoot.FullName, "file1.txt"); + await File.WriteAllTextAsync(accessibleFile1, new string('A', 1000)); + + var accessibleFile2 = Path.Combine(dataRoot.FullName, "file2.txt"); + await File.WriteAllTextAsync(accessibleFile2, new string('B', 2000)); + + var inaccessibleFile = Path.Combine(dataRoot.FullName, "inaccessible.txt"); + await File.WriteAllTextAsync(inaccessibleFile, new string('C', 5000)); + + FileSecurity? originalSecurity = null; + UnixFileMode? originalMode = null; + + try + { + if (OperatingSystem.IsWindows()) + { + originalSecurity = new FileInfo(inaccessibleFile).GetAccessControl(); + var sid = WindowsIdentity.GetCurrent().User!; + var denyRule = new FileSystemAccessRule(sid, + FileSystemRights.ReadData | FileSystemRights.ReadAttributes, + AccessControlType.Deny); + var sec = new FileInfo(inaccessibleFile).GetAccessControl(); + sec.AddAccessRule(denyRule); + new FileInfo(inaccessibleFile).SetAccessControl(sec); + } + else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + originalMode = File.GetUnixFileMode(inaccessibleFile); + File.SetUnixFileMode(inaccessibleFile, UnixFileMode.None); + } + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var osPlatform = OperatingSystem.IsWindows() ? OSPlatforms.Windows : + OperatingSystem.IsLinux() ? OSPlatforms.Linux : OSPlatforms.MacOs; + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + osPlatform, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(dataRoot.FullName); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var monitorData = await _inventoryProcessData.InventoryMonitorObservable.FirstAsync(); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + part.FileDescriptions.Should().HaveCount(3); + + var inaccessibleCount = part.FileDescriptions.Count(f => !f.IsAccessible); + + if (inaccessibleCount == 0) + { + Assert.Ignore( + "File permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + monitorData.IdentifiedFiles.Should().Be(3); + monitorData.IdentifiedVolume.Should().Be(3000, "inaccessible files should not be counted in the volume"); + + part.FileDescriptions.Count(f => f.IsAccessible).Should().Be(2); + part.FileDescriptions.Count(f => !f.IsAccessible).Should().Be(1); + } + finally + { + if (OperatingSystem.IsWindows() && originalSecurity != null) + { + new FileInfo(inaccessibleFile).SetAccessControl(originalSecurity); + } + else if ((OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) && originalMode.HasValue) + { + File.SetUnixFileMode(inaccessibleFile, originalMode.Value); + } + } + } + + [Test] + public async Task InventoryContinues_AfterEncountering_InaccessibleItems() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + + var dir1 = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "dir1")); + await File.WriteAllTextAsync(Path.Combine(dir1.FullName, "file1.txt"), "content1"); + + var dir2 = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "dir2_inaccessible")); + await File.WriteAllTextAsync(Path.Combine(dir2.FullName, "file2.txt"), "content2"); + + var dir3 = Directory.CreateDirectory(Path.Combine(dataRoot.FullName, "dir3")); + await File.WriteAllTextAsync(Path.Combine(dir3.FullName, "file3.txt"), "content3"); + + FileSystemAccessRule? denyRule = null; + UnixFileMode? originalMode = null; + + try + { + if (OperatingSystem.IsWindows()) + { + var sid = WindowsIdentity.GetCurrent().User!; + denyRule = new FileSystemAccessRule(sid, + FileSystemRights.ListDirectory, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Deny); + var sec = dir2.GetAccessControl(); + sec.AddAccessRule(denyRule); + dir2.SetAccessControl(sec); + } + else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + originalMode = File.GetUnixFileMode(dir2.FullName); + File.SetUnixFileMode(dir2.FullName, UnixFileMode.None); + } + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var osPlatform = OperatingSystem.IsWindows() ? OSPlatforms.Windows : + OperatingSystem.IsLinux() ? OSPlatforms.Linux : OSPlatforms.MacOs; + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + osPlatform, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(dataRoot.FullName); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + var dir1Desc = part.DirectoryDescriptions.FirstOrDefault(d => d.RelativePath.Contains("dir1")); + dir1Desc.Should().NotBeNull(); + dir1Desc.IsAccessible.Should().BeTrue(); + + var dir3Desc = part.DirectoryDescriptions.FirstOrDefault(d => d.RelativePath.Contains("dir3")); + dir3Desc.Should().NotBeNull(); + dir3Desc.IsAccessible.Should().BeTrue(); + + var dir2Desc = part.DirectoryDescriptions.FirstOrDefault(d => d.RelativePath.Contains("dir2")); + dir2Desc.Should().NotBeNull(); + + if (dir2Desc.IsAccessible) + { + Assert.Ignore( + "Directory permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + dir2Desc.IsAccessible.Should().BeFalse(); + + part.FileDescriptions.Should().Contain(f => f.RelativePath.Contains("file1.txt")); + part.FileDescriptions.Should().Contain(f => f.RelativePath.Contains("file3.txt")); + + part.FileDescriptions.Should().NotContain(f => f.RelativePath.Contains("file2.txt")); + } + finally + { + if (OperatingSystem.IsWindows() && denyRule != null) + { + var sec = dir2.GetAccessControl(); + sec.RemoveAccessRule(denyRule); + dir2.SetAccessControl(sec); + } + else if ((OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) && originalMode.HasValue) + { + File.SetUnixFileMode(dir2.FullName, originalMode.Value); + } + } + } + + [Test] + [Platform(Include = "Win")] + public async Task InaccessibleFile_AsInventoryPartOfTypeFile_Windows() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + var testFile = Path.Combine(dataRoot.FullName, "test.txt"); + await File.WriteAllTextAsync(testFile, new string('A', 1000)); + + new FileInfo(testFile).GetAccessControl(); + var sid = WindowsIdentity.GetCurrent().User!; + var denyRule = new FileSystemAccessRule(sid, + FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes, + AccessControlType.Deny); + + try + { + var sec = new FileInfo(testFile).GetAccessControl(); + sec.AddAccessRule(denyRule); + new FileInfo(testFile).SetAccessControl(sec); + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + OSPlatforms.Windows, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(testFile); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + part.InventoryPartType.Should().Be(FileSystemTypes.File); + + var fileDesc = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("test.txt")); + fileDesc.Should().NotBeNull(); + + if (fileDesc.IsAccessible) + { + Assert.Ignore( + "File permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + fileDesc.IsAccessible.Should().BeFalse(); + fileDesc.RelativePath.Should().Be("/test.txt"); + } + finally + { + var sec = new FileInfo(testFile).GetAccessControl(); + sec.RemoveAccessRule(denyRule); + new FileInfo(testFile).SetAccessControl(sec); + } + } + + + [Test] + [Platform(Include = "Linux,MacOsX")] + public async Task InaccessibleFile_AsInventoryPartOfTypeFile_Posix() + { + var dataRoot = _testDirectoryService.CreateSubTestDirectory("data"); + var testFile = Path.Combine(dataRoot.FullName, "test.txt"); + await File.WriteAllTextAsync(testFile, new string('A', 1000)); + + var originalMode = File.GetUnixFileMode(testFile); + + try + { + File.SetUnixFileMode(testFile, UnixFileMode.None); + + var sessionMember = new SessionMember + { + Endpoint = new(), + PrivateData = new() { MachineName = "Test" } + }; + var dataNode = new DataNode { Id = "DN1", Code = "A" }; + var sessionSettings = new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Sensitive + }; + + var loggerMock = new Mock>(); + var inventoryFileAnalyzerLoggerMock = new Mock>(); + + var inventorySaver = new InventorySaver(); + var inventoryFileAnalyzer = new InventoryFileAnalyzer(FingerprintModes.Rsync, _inventoryProcessData, inventorySaver, + inventoryFileAnalyzerLoggerMock.Object); + + var inventoryIndexer = new InventoryIndexer(); + + var osPlatform = OperatingSystem.IsLinux() ? OSPlatforms.Linux : OSPlatforms.MacOs; + + var builder = new InventoryBuilder( + sessionMember, + dataNode, + sessionSettings, + _inventoryProcessData, + osPlatform, + FingerprintModes.Rsync, + loggerMock.Object, + inventoryFileAnalyzer, + inventorySaver, + inventoryIndexer + ); + + builder.AddInventoryPart(testFile); + + var inventoryFile = Path.Combine(_testDirectoryService.TestDirectory.FullName, "inventory.zip"); + await builder.BuildBaseInventoryAsync(inventoryFile); + + var inventory = builder.Inventory; + var part = inventory.InventoryParts.First(); + + part.InventoryPartType.Should().Be(FileSystemTypes.File); + + var fileDesc = part.FileDescriptions.FirstOrDefault(f => f.RelativePath.Contains("test.txt")); + fileDesc.Should().NotBeNull(); + + if (fileDesc.IsAccessible) + { + Assert.Ignore( + "File permissions were not enforced by the OS - test cannot verify access control (likely running with elevated permissions)"); + } + + fileDesc.IsAccessible.Should().BeFalse(); + fileDesc.RelativePath.Should().Be("/test.txt"); + } + finally + { + File.SetUnixFileMode(testFile, originalMode); + } + } +} +#pragma warning restore CA1416 \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Models/Comparisons/ContentIdentityAccessTests.cs b/tests/ByteSync.Client.UnitTests/Models/Comparisons/ContentIdentityAccessTests.cs new file mode 100644 index 000000000..25f93c854 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Models/Comparisons/ContentIdentityAccessTests.cs @@ -0,0 +1,313 @@ +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Models.Comparisons; + +[TestFixture] +public class ContentIdentityAccessTests +{ + private static (Inventory invA, InventoryPart partA, Inventory invB, InventoryPart partB) CreateInventories() + { + var invA = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M1" }; + var invB = new Inventory { InventoryId = "INV_B", Code = "B", Endpoint = new(), MachineName = "M2" }; + var partA = new InventoryPart(invA, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(invB, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + return (invA, partA, invB, partB); + } + + [Test] + public void HasAccessIssue_ReturnsFalse_WhenAllFilesAccessible() + { + var (_, partA, _, _) = CreateInventories(); + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(file); + + ci.HasAccessIssue.Should().BeFalse(); + } + + [Test] + public void HasAccessIssue_ReturnsTrue_WhenFileIsInaccessible() + { + var (_, partA, _, _) = CreateInventories(); + + var ci = new ContentIdentity(null); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + IsAccessible = false + }; + ci.Add(file); + + ci.HasAccessIssue.Should().BeTrue(); + } + + [Test] + public void HasAccessIssue_ReturnsTrue_WhenAccessIssueInventoryPartIsSet() + { + var (_, partA, _, partB) = CreateInventories(); + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(file); + + ci.AddAccessIssue(partB); + + ci.HasAccessIssue.Should().BeTrue(); + } + + [Test] + public void HasAccessIssueFor_ReturnsTrue_WhenInventoryPartMarked() + { + var (invA, partA, invB, partB) = CreateInventories(); + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(file); + + ci.AddAccessIssue(partB); + + ci.HasAccessIssueFor(invB).Should().BeTrue(); + ci.HasAccessIssueFor(invA).Should().BeFalse(); + } + + [Test] + public void HasAccessIssueFor_ReturnsTrue_WhenFileDescriptionInaccessibleForInventory() + { + var (invA, partA, invB, partB) = CreateInventories(); + + var ci = new ContentIdentity(null); + + // Accessible file on A + var fileA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(fileA); + + // Inaccessible file on B + var fileB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + IsAccessible = false + }; + ci.Add(fileB); + + ci.HasAccessIssueFor(invB).Should().BeTrue(); + ci.HasAccessIssueFor(invA).Should().BeFalse(); + } + + [Test] + public void HasAccessIssueFor_ReturnsFalse_WhenInventoryNotPresent() + { + var (_, partA, invB, _) = CreateInventories(); + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(file); + + ci.HasAccessIssueFor(invB).Should().BeFalse(); + } + + [Test] + public void AddAccessIssue_AddsInventoryPartToCollection() + { + var (_, _, _, partB) = CreateInventories(); + + var ci = new ContentIdentity(null); + + ci.AccessIssueInventoryParts.Should().BeEmpty(); + + ci.AddAccessIssue(partB); + + ci.AccessIssueInventoryParts.Should().Contain(partB); + ci.AccessIssueInventoryParts.Should().HaveCount(1); + } + + [Test] + public void AddAccessIssue_DoesNotDuplicate_WhenCalledMultipleTimes() + { + var (_, _, _, partB) = CreateInventories(); + + var ci = new ContentIdentity(null); + + ci.AddAccessIssue(partB); + ci.AddAccessIssue(partB); + ci.AddAccessIssue(partB); + + ci.AccessIssueInventoryParts.Should().HaveCount(1); + } + + [Test] + public void IsPresentIn_ReturnsTrue_WhenInventoryPartHasFileDescription() + { + var (_, partA, _, _) = CreateInventories(); + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(file); + + ci.IsPresentIn(partA).Should().BeTrue(); + } + + [Test] + public void IsPresentIn_ReturnsTrue_EvenWhenFileIsInaccessible() + { + var (_, partA, _, _) = CreateInventories(); + + var ci = new ContentIdentity(null); + var file = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + IsAccessible = false + }; + ci.Add(file); + + ci.IsPresentIn(partA).Should().BeTrue(); + } + + [Test] + public void IsPresentIn_Inventory_ReturnsTrue_WhenAnyPartOfInventoryHasContent() + { + var invA = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M1" }; + var part1 = new InventoryPart(invA, "c:/root1", FileSystemTypes.Directory) { Code = "A1" }; + var part2 = new InventoryPart(invA, "c:/root2", FileSystemTypes.Directory) { Code = "A2" }; + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file = new FileDescription + { + InventoryPart = part2, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(file); + + ci.IsPresentIn(invA).Should().BeTrue(); + ci.IsPresentIn(part1).Should().BeFalse(); + ci.IsPresentIn(part2).Should().BeTrue(); + } + + [Test] + public void GetInventories_ReturnsOnlyInventoriesWithContent() + { + var (invA, partA, invB, partB) = CreateInventories(); + + var ci = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + + var fileA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + ci.Add(fileA); + + var fileB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = false + }; + ci.Add(fileB); + + var inventories = ci.GetInventories(); + inventories.Should().HaveCount(2); + inventories.Should().Contain(invA); + inventories.Should().Contain(invB); + } + + [Test] + public void GetInventoryParts_ReturnsAllPartsWithContent() + { + var (_, partA, _, partB) = CreateInventories(); + + var ci = new ContentIdentity(null); + + var fileA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + IsAccessible = false + }; + ci.Add(fileA); + + var fileB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + IsAccessible = true, + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow + }; + ci.Add(fileB); + + var parts = ci.GetInventoryParts(); + parts.Should().HaveCount(2); + parts.Should().Contain(partA); + parts.Should().Contain(partB); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs new file mode 100644 index 000000000..b033184d7 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs @@ -0,0 +1,277 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Actions; +using ByteSync.Common.Business.Inventories; +using ByteSync.Interfaces.Repositories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class AtomicActionConsistencyCheckerAccessTests +{ + private static ComparisonItem BuildComparisonItem(InventoryPart src, InventoryPart dst, bool sourceAccessible, bool targetAccessible) + { + var item = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "p", "/p")); + + // Source identity + var srcCi = new ContentIdentity(null); + var srcFd = new FileDescription + { InventoryPart = src, RelativePath = "/p", Size = 1, CreationTimeUtc = DateTime.UtcNow, LastWriteTimeUtc = DateTime.UtcNow }; + srcFd.IsAccessible = sourceAccessible; + srcCi.Add(srcFd); + item.AddContentIdentity(srcCi); + + // Target identity + var dstCi = new ContentIdentity(null); + var dstFd = new FileDescription + { InventoryPart = dst, RelativePath = "/p", Size = 1, CreationTimeUtc = DateTime.UtcNow, LastWriteTimeUtc = DateTime.UtcNow }; + dstFd.IsAccessible = targetAccessible; + dstCi.Add(dstFd); + item.AddContentIdentity(dstCi); + + return item; + } + + private static (InventoryPart src, InventoryPart dst) BuildParts() + { + var invA = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var invB = new Inventory { InventoryId = "INV_B", Code = "B", Endpoint = new(), MachineName = "M" }; + var src = new InventoryPart(invA, "c:/a", FileSystemTypes.Directory) { Code = "A1" }; + var dst = new InventoryPart(invB, "c:/b", FileSystemTypes.Directory) { Code = "B1" }; + + return (src, dst); + } + + [Test] + public void Synchronize_Fails_When_Source_Not_Accessible() + { + var (src, dst) = BuildParts(); + var item = BuildComparisonItem(src, dst, sourceAccessible: false, targetAccessible: true); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentOnly, + Source = new DataPart("A", src), + Destination = new DataPart("B", dst), + ComparisonItem = item + }; + + var repoMock = new Mock(); + repoMock.Setup(r => r.GetAtomicActions(It.IsAny())).Returns(new List()); + var checker = new AtomicActionConsistencyChecker(repoMock.Object); + var result = checker.CheckCanAdd(action, item); + + result.ValidationResults.Should().HaveCount(1); + result.ValidationResults[0].IsValid.Should().BeFalse(); + result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.SourceNotAccessible); + } + + [Test] + public void Synchronize_WithSourceCoreNull_DoesNotThrowException() + { + var (src, dst) = BuildParts(); + var item = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "p", "/p")); + + var srcCi = new ContentIdentity(null); + var srcFd = new FileDescription + { + InventoryPart = src, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + srcCi.Add(srcFd); + item.AddContentIdentity(srcCi); + + var dstCi = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash", Size = 1 }); + var dstFd = new FileDescription + { + InventoryPart = dst, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + dstCi.Add(dstFd); + item.AddContentIdentity(dstCi); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentAndDate, + Source = new DataPart("A", src), + Destination = new DataPart("B", dst), + ComparisonItem = item + }; + + var repoMock = new Mock(); + repoMock.Setup(r => r.GetAtomicActions(It.IsAny())).Returns(new List()); + var checker = new AtomicActionConsistencyChecker(repoMock.Object); + + Action act = () => checker.CheckCanAdd(action, item); + + act.Should().NotThrow(); + } + + [Test] + public void Synchronize_WithTargetCoreNull_DoesNotThrowException() + { + var (src, dst) = BuildParts(); + var item = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "p", "/p")); + + var srcCi = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash", Size = 1 }); + var srcFd = new FileDescription + { + InventoryPart = src, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + srcCi.Add(srcFd); + item.AddContentIdentity(srcCi); + + var dstCi = new ContentIdentity(null); + var dstFd = new FileDescription + { + InventoryPart = dst, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + dstCi.Add(dstFd); + item.AddContentIdentity(dstCi); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentAndDate, + Source = new DataPart("A", src), + Destination = new DataPart("B", dst), + ComparisonItem = item + }; + + var repoMock = new Mock(); + repoMock.Setup(r => r.GetAtomicActions(It.IsAny())).Returns(new List()); + var checker = new AtomicActionConsistencyChecker(repoMock.Object); + + Action act = () => checker.CheckCanAdd(action, item); + + act.Should().NotThrow(); + } + + [Test] + public void SynchronizeContentOnly_WithBothCoresNull_DoesNotThrowException() + { + var (src, dst) = BuildParts(); + var item = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "p", "/p")); + + var srcCi = new ContentIdentity(null); + var srcFd = new FileDescription + { + InventoryPart = src, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + srcCi.Add(srcFd); + item.AddContentIdentity(srcCi); + + var dstCi = new ContentIdentity(null); + var dstFd = new FileDescription + { + InventoryPart = dst, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + dstCi.Add(dstFd); + item.AddContentIdentity(dstCi); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentOnly, + Source = new DataPart("A", src), + Destination = new DataPart("B", dst), + ComparisonItem = item + }; + + var repoMock = new Mock(); + repoMock.Setup(r => r.GetAtomicActions(It.IsAny())).Returns(new List()); + var checker = new AtomicActionConsistencyChecker(repoMock.Object); + + Action act = () => checker.CheckCanAdd(action, item); + + act.Should().NotThrow(); + } + + [Test] + public void Synchronize_WithInaccessibleTargetAndNullCore_DoesNotThrowException() + { + var (src, dst) = BuildParts(); + var item = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "p", "/p")); + + var srcCi = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash", Size = 1 }); + var srcFd = new FileDescription + { + InventoryPart = src, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + srcCi.Add(srcFd); + item.AddContentIdentity(srcCi); + + var dstCi = new ContentIdentity(null); + var dstFd = new FileDescription + { + InventoryPart = dst, + RelativePath = "/p", + Size = 1, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = false + }; + dstCi.Add(dstFd); + item.AddContentIdentity(dstCi); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentAndDate, + Source = new DataPart("A", src), + Destination = new DataPart("B", dst), + ComparisonItem = item + }; + + var repoMock = new Mock(); + repoMock.Setup(r => r.GetAtomicActions(It.IsAny())).Returns(new List()); + var checker = new AtomicActionConsistencyChecker(repoMock.Object); + var result = checker.CheckCanAdd(action, item); + + result.ValidationResults.Should().HaveCount(1); + result.ValidationResults[0].IsValid.Should().BeFalse(); + result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + } + + // Note: Additional tests for multi-target scenarios and various action operators + // are covered by integration tests. The unit test above covers the core access control + // for inaccessible sources. +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/InitialStatusBuilderTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/InitialStatusBuilderTests.cs index 6f699d68b..19459cf6f 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Comparisons/InitialStatusBuilderTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/InitialStatusBuilderTests.cs @@ -54,7 +54,7 @@ public void Test_BuildStatus_ForFile_With_FileDescription() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().BeEmpty(); @@ -89,7 +89,7 @@ public void Test_BuildStatus_ForFile_Without_FileDescription() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().NotBeEmpty(); @@ -131,7 +131,7 @@ public void Test_BuildStatus_ForDirectory_With_DirectoryDescription() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().BeEmpty(); @@ -166,7 +166,7 @@ public void Test_BuildStatus_ForDirectory_Without_DirectoryDescription() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().NotBeEmpty(); @@ -241,7 +241,7 @@ public void Test_BuildStatus_ForFile_With_Multiple_ContentIdentities() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().BeEmpty(); @@ -311,7 +311,7 @@ public void Test_BuildStatus_ForFile_With_Different_LastWriteTimes() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().BeEmpty(); @@ -384,7 +384,7 @@ public void Test_BuildStatus_ForFile_With_Multiple_Inventories() var initialStatusBuilder = new InitialStatusBuilder(); // Act - initialStatusBuilder.BuildStatus(comparisonItem, new[] { inventory1, inventory2 }); + initialStatusBuilder.BuildStatus(comparisonItem, [inventory1, inventory2]); // Assert comparisonItem.ContentRepartition.MissingInventories.Should().BeEmpty(); diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/InventoryComparerPropagateAccessIssuesTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/InventoryComparerPropagateAccessIssuesTests.cs new file mode 100644 index 000000000..6adc0461d --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/InventoryComparerPropagateAccessIssuesTests.cs @@ -0,0 +1,638 @@ +using System.IO.Compression; +using ByteSync.Business.Sessions; +using ByteSync.Common.Business.EndPoints; +using ByteSync.Common.Business.Inventories; +using ByteSync.Common.Business.Misc; +using ByteSync.Common.Controls.Json; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class InventoryComparerPropagateAccessIssuesTests +{ + private string _tempDirectory = null!; + + [SetUp] + public void Setup() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + + private static string CreateInventoryZipFile(string directory, Inventory inventory) + { + var zipPath = Path.Combine(directory, $"{Guid.NewGuid()}.zip"); + + using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); + var entry = zip.CreateEntry("inventory.json"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + var json = JsonHelper.Serialize(inventory); + writer.Write(json); + + return zipPath; + } + + private static Inventory CreateInventoryWithInaccessibleDirectory(string inventoryId, string code, string inaccessibleDirPath, + string fileUnderInaccessibleDir) + { + var inventory = new Inventory + { + InventoryId = inventoryId, + Code = code, + MachineName = $"Machine{code}", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = $"CII_{code}", + OSPlatform = OSPlatforms.Windows + } + }; + + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = $"{code}1" }; + inventory.Add(part); + + var inaccessibleDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = inaccessibleDirPath, + IsAccessible = false + }; + part.DirectoryDescriptions.Add(inaccessibleDir); + + var accessibleDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = "/accessible", + IsAccessible = true + }; + part.DirectoryDescriptions.Add(accessibleDir); + + var fileUnderInaccessible = new FileDescription + { + InventoryPart = part, + RelativePath = fileUnderInaccessibleDir, + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + part.FileDescriptions.Add(fileUnderInaccessible); + + return inventory; + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithFileUnderInaccessibleDirectory_DoesNotCreateVirtualContentIdentityWhenFileExists() + { + var inventory = CreateInventoryWithInaccessibleDirectory("INV_A", "A", "/inaccessible", "/inaccessible/file.txt"); + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + var fileItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/inaccessible/file.txt"); + fileItem.Should().NotBeNull(); + + fileItem.ContentIdentities.Should().HaveCount(1); + fileItem.ContentIdentities.All(ci => ci.Core != null).Should().BeTrue(); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithFileUnderInaccessibleDirectory_CreatesVirtualContentIdentityForMissingPart() + { + var inventoryA = new Inventory + { + InventoryId = "INV_A", + Code = "A", + MachineName = "MachineA", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_A", + OSPlatform = OSPlatforms.Windows + } + }; + + var partA = new InventoryPart(inventoryA, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + inventoryA.Add(partA); + + var inaccessibleDirA = new DirectoryDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible", + IsAccessible = false + }; + partA.DirectoryDescriptions.Add(inaccessibleDirA); + + var fileInA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + partA.FileDescriptions.Add(fileInA); + + var inventoryB = new Inventory + { + InventoryId = "INV_B", + Code = "B", + MachineName = "MachineB", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_B", + OSPlatform = OSPlatforms.Windows + } + }; + + var partB = new InventoryPart(inventoryB, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + inventoryB.Add(partB); + + var inaccessibleDirB = new DirectoryDescription + { + InventoryPart = partB, + RelativePath = "/inaccessible", + IsAccessible = false + }; + partB.DirectoryDescriptions.Add(inaccessibleDirB); + + var inventoryFileA = CreateInventoryZipFile(_tempDirectory, inventoryA); + var inventoryFileB = CreateInventoryZipFile(_tempDirectory, inventoryB); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFileA); + comparer.AddInventory(inventoryFileB); + + var result = comparer.Compare(); + + var fileItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/inaccessible/file.txt"); + fileItem.Should().NotBeNull(); + + fileItem.ContentIdentities.Should().HaveCount(2); + + var virtualContentIdentity = fileItem.ContentIdentities.FirstOrDefault(ci => ci.Core == null); + virtualContentIdentity.Should().NotBeNull(); + virtualContentIdentity.AccessIssueInventoryParts.Should().Contain(partB); + + var virtualFileDescription = virtualContentIdentity.FileSystemDescriptions + .OfType() + .FirstOrDefault(fd => !fd.IsAccessible); + virtualFileDescription.Should().NotBeNull(); + virtualFileDescription.RelativePath.Should().Be("/inaccessible/file.txt"); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithDirectoryUnderInaccessibleDirectory_MarksExistingContentIdentity() + { + var inventory = new Inventory + { + InventoryId = "INV_A", + Code = "A", + MachineName = "MachineA", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_A", + OSPlatform = OSPlatforms.Windows + } + }; + + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + inventory.Add(part); + + var inaccessibleDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = "/inaccessible", + IsAccessible = false + }; + part.DirectoryDescriptions.Add(inaccessibleDir); + + var subDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = "/inaccessible/subdir", + IsAccessible = true + }; + part.DirectoryDescriptions.Add(subDir); + + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + var dirItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/inaccessible/subdir"); + dirItem.Should().NotBeNull(); + + dirItem.ContentIdentities.Should().HaveCount(1); + var contentIdentity = dirItem.ContentIdentities.First(); + contentIdentity.AccessIssueInventoryParts.Should().Contain(part); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithFileNotUnderInaccessibleDirectory_DoesNotCreateVirtualContentIdentity() + { + var inventory = CreateInventoryWithInaccessibleDirectory("INV_A", "A", "/inaccessible", "/accessible/file.txt"); + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + var fileItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/accessible/file.txt"); + fileItem.Should().NotBeNull(); + + fileItem.ContentIdentities.Should().HaveCount(1); + fileItem.ContentIdentities.All(ci => ci.Core != null).Should().BeTrue(); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithFileAlreadyHavingContentForPart_DoesNotCreateDuplicate() + { + var inventory = CreateInventoryWithInaccessibleDirectory("INV_A", "A", "/inaccessible", "/inaccessible/file.txt"); + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + var fileItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/inaccessible/file.txt"); + fileItem.Should().NotBeNull(); + + var contentIdentitiesForPart = fileItem.ContentIdentities + .Where(ci => ci.GetInventoryParts().Contains(inventory.InventoryParts[0])) + .ToList(); + + contentIdentitiesForPart.Should().HaveCount(1); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithEmptyPath_IgnoresItem() + { + var inventory = new Inventory + { + InventoryId = "INV_A", + Code = "A", + MachineName = "MachineA", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_A", + OSPlatform = OSPlatforms.Windows + } + }; + + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + inventory.Add(part); + + var inaccessibleDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = "/inaccessible", + IsAccessible = false + }; + part.DirectoryDescriptions.Add(inaccessibleDir); + + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + result.ComparisonItems.Should().NotContain(item => string.IsNullOrWhiteSpace(item.PathIdentity.LinkingKeyValue)); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithRootPath_IgnoresItem() + { + var inventory = new Inventory + { + InventoryId = "INV_A", + Code = "A", + MachineName = "MachineA", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_A", + OSPlatform = OSPlatforms.Windows + } + }; + + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + inventory.Add(part); + + var inaccessibleDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = "/inaccessible", + IsAccessible = false + }; + part.DirectoryDescriptions.Add(inaccessibleDir); + + var rootDir = new DirectoryDescription + { + InventoryPart = part, + RelativePath = "/", + IsAccessible = true + }; + part.DirectoryDescriptions.Add(rootDir); + + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + var rootItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/"); + if (rootItem != null) + { + rootItem.ContentIdentities.Should().NotContain(ci => ci.AccessIssueInventoryParts.Any()); + } + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithNestedInaccessibleAncestors_DetectsCorrectly() + { + var inventoryA = new Inventory + { + InventoryId = "INV_A", + Code = "A", + MachineName = "MachineA", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_A", + OSPlatform = OSPlatforms.Windows + } + }; + + var partA = new InventoryPart(inventoryA, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + inventoryA.Add(partA); + + var inaccessibleDir1 = new DirectoryDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible1", + IsAccessible = false + }; + partA.DirectoryDescriptions.Add(inaccessibleDir1); + + var inaccessibleDir2 = new DirectoryDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible1/inaccessible2", + IsAccessible = false + }; + partA.DirectoryDescriptions.Add(inaccessibleDir2); + + var fileUnderNested = new FileDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible1/inaccessible2/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + partA.FileDescriptions.Add(fileUnderNested); + + var inventoryB = new Inventory + { + InventoryId = "INV_B", + Code = "B", + MachineName = "MachineB", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_B", + OSPlatform = OSPlatforms.Windows + } + }; + + var partB = new InventoryPart(inventoryB, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + inventoryB.Add(partB); + + var inaccessibleDir1B = new DirectoryDescription + { + InventoryPart = partB, + RelativePath = "/inaccessible1", + IsAccessible = false + }; + partB.DirectoryDescriptions.Add(inaccessibleDir1B); + + var inventoryFileA = CreateInventoryZipFile(_tempDirectory, inventoryA); + var inventoryFileB = CreateInventoryZipFile(_tempDirectory, inventoryB); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFileA); + comparer.AddInventory(inventoryFileB); + + var result = comparer.Compare(); + + var fileItem = + result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/inaccessible1/inaccessible2/file.txt"); + fileItem.Should().NotBeNull(); + + fileItem.ContentIdentities.Should().HaveCount(2); + + var virtualContentIdentity = fileItem.ContentIdentities.FirstOrDefault(ci => ci.Core == null); + virtualContentIdentity.Should().NotBeNull(); + virtualContentIdentity.AccessIssueInventoryParts.Should().Contain(partB); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithMultipleInventories_PropagatesPerInventoryPart() + { + var inventoryA = new Inventory + { + InventoryId = "INV_A", + Code = "A", + MachineName = "MachineA", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_A", + OSPlatform = OSPlatforms.Windows + } + }; + + var partA = new InventoryPart(inventoryA, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + inventoryA.Add(partA); + + var inaccessibleDirA = new DirectoryDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible", + IsAccessible = false + }; + partA.DirectoryDescriptions.Add(inaccessibleDirA); + + var fileInA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/inaccessible/file.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + partA.FileDescriptions.Add(fileInA); + + var inventoryB = new Inventory + { + InventoryId = "INV_B", + Code = "B", + MachineName = "MachineB", + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = "CII_B", + OSPlatform = OSPlatforms.Windows + } + }; + + var partB = new InventoryPart(inventoryB, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + inventoryB.Add(partB); + + var inaccessibleDirB = new DirectoryDescription + { + InventoryPart = partB, + RelativePath = "/inaccessible", + IsAccessible = false + }; + partB.DirectoryDescriptions.Add(inaccessibleDirB); + + var inventoryFileA = CreateInventoryZipFile(_tempDirectory, inventoryA); + var inventoryFileB = CreateInventoryZipFile(_tempDirectory, inventoryB); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Tree, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFileA); + comparer.AddInventory(inventoryFileB); + + var result = comparer.Compare(); + + var fileItem = result.ComparisonItems.FirstOrDefault(item => item.PathIdentity.LinkingKeyValue == "/inaccessible/file.txt"); + fileItem.Should().NotBeNull(); + fileItem.ContentIdentities.Should().HaveCount(2); + + var virtualContentIdentity = fileItem.ContentIdentities.FirstOrDefault(ci => ci.Core == null); + virtualContentIdentity.Should().NotBeNull(); + virtualContentIdentity.AccessIssueInventoryParts.Should().Contain(partB); + } + + [Test] + public void PropagateAccessIssuesFromAncestors_WithFlatMatchingMode_DoesNotPropagate() + { + var inventory = CreateInventoryWithInaccessibleDirectory("INV_A", "A", "/inaccessible", "/inaccessible/file.txt"); + var inventoryFile = CreateInventoryZipFile(_tempDirectory, inventory); + + var sessionSettings = new SessionSettings + { + DataType = DataTypes.FilesDirectories, + MatchingMode = MatchingModes.Flat, + LinkingCase = LinkingCases.Insensitive + }; + + var initialStatusBuilder = new InitialStatusBuilder(); + using var comparer = new InventoryComparer(sessionSettings, initialStatusBuilder); + comparer.AddInventory(inventoryFile); + + var result = comparer.Compare(); + + var fileItem = result.ComparisonItems.FirstOrDefault(item => + item.PathIdentity.LinkingKeyValue == "/inaccessible/file.txt" || item.PathIdentity.FileName == "file.txt"); + if (fileItem != null) + { + fileItem.ContentIdentities.Should().HaveCount(1); + fileItem.ContentIdentities.All(ci => ci.Core != null).Should().BeTrue(); + } + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherConditionsMatchTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherConditionsMatchTests.cs new file mode 100644 index 000000000..b97815e5f --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherConditionsMatchTests.cs @@ -0,0 +1,203 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Interfaces.Controls.Comparisons; +using ByteSync.Interfaces.Repositories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherConditionsMatchTests +{ + private Mock _consistencyCheckerMock = null!; + private Mock _repositoryMock = null!; + private SynchronizationRuleMatcher _matcher = null!; + + [SetUp] + public void SetUp() + { + _consistencyCheckerMock = new Mock(); + _repositoryMock = new Mock(); + + var extractor = new ContentIdentityExtractor(); + var matchers = new IConditionMatcher[] + { + new ContentConditionMatcher(extractor), + new SizeConditionMatcher(extractor), + new DateConditionMatcher(extractor), + new PresenceConditionMatcher(extractor), + new NameConditionMatcher() + }; + var factory = new ConditionMatcherFactory(matchers); + + _matcher = new SynchronizationRuleMatcher(_consistencyCheckerMock.Object, _repositoryMock.Object, factory); + } + + [Test] + public void ConditionsMatch_WithEmptyConditions_ReturnsFalse() + { + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _matcher.ConditionsMatch(rule, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionsMatch_WithDifferentFileSystemType_ReturnsFalse() + { + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + rule.Conditions.Add(condition); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.Directory, "/dir", "dir", "/dir")); + + var result = _matcher.ConditionsMatch(rule, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionsMatch_WithConditionModeAll_AllConditionsMustMatch() + { + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var condition1 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + var condition2 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + rule.Conditions.Add(condition1); + rule.Conditions.Add(condition2); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _matcher.ConditionsMatch(rule, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionsMatch_WithConditionModeAll_OneConditionFails_ReturnsFalse() + { + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var condition1 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + var condition2 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "other.txt" + }; + rule.Conditions.Add(condition1); + rule.Conditions.Add(condition2); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _matcher.ConditionsMatch(rule, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionsMatch_WithConditionModeAny_OneConditionMatches_ReturnsTrue() + { + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.Any); + var condition1 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + var condition2 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "other.txt" + }; + rule.Conditions.Add(condition1); + rule.Conditions.Add(condition2); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _matcher.ConditionsMatch(rule, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionsMatch_WithConditionModeAny_NoConditionMatches_ReturnsFalse() + { + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.Any); + var condition1 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "other1.txt" + }; + var condition2 = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "other2.txt" + }; + rule.Conditions.Add(condition1); + rule.Conditions.Add(condition2); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _matcher.ConditionsMatch(rule, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatches_WithUnknownComparisonProperty_ReturnsFalse() + { + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = (ComparisonProperty)999, + ConditionOperator = ConditionOperatorTypes.Equals + }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var factory = SynchronizationRuleMatcherTestHelper.CreateConditionMatcherFactory(); + var matcher = factory.GetMatcher(condition.ComparisonProperty); + var result = matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherContentTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherContentTests.cs new file mode 100644 index 000000000..deb9fd3f0 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherContentTests.cs @@ -0,0 +1,290 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherContentTests +{ + private ContentConditionMatcher _matcher = null!; + + [SetUp] + public void SetUp() + { + var extractor = new ContentIdentityExtractor(); + _matcher = new ContentConditionMatcher(extractor); + } + + [Test] + public void ConditionMatchesContent_WithDirectory_ReturnsFalse() + { + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.Directory, "/dir", "dir", "/dir")); + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesContent_WithContentIdentityHasAnalysisError_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescription = new FileDescription + { + InventoryPart = part, + RelativePath = "/file.txt", + AnalysisErrorDescription = "Test error" + }; + contentIdentity.Add(fileDescription); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", part), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesContent_WithContentIdentityHasAccessIssue_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = part, RelativePath = "/file.txt" }); + contentIdentity.AddAccessIssue(part); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", part), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesContent_Equals_WithBothNull_ReturnsTrue() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var condition = new AtomicCondition + { + Source = null!, + Destination = null, + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesContent_Equals_WithSourceNullDestinationNotNull_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescription = new FileDescription { InventoryPart = part, RelativePath = "/file.txt" }; + contentIdentity.Add(fileDescription); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = null!, + Destination = new DataPart("A", part), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesContent_Equals_WithSourceNotNullDestinationNull_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescription = new FileDescription { InventoryPart = part, RelativePath = "/file.txt" }; + contentIdentity.Add(fileDescription); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", part), + Destination = null, + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesContent_Equals_WithSameHash_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash123", Size = 100 }); + var fileDescriptionA = new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }; + var fileDescriptionB = new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }; + contentIdentity.Add(fileDescriptionA); + contentIdentity.Add(fileDescriptionB); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesContent_Equals_WithDifferentHash_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 100 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesContent_NotEquals_WithSourceNullDestinationNotNull_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = part, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = null!, + Destination = new DataPart("A", part), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.NotEquals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesContent_NotEquals_WithDifferentHash_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 100 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = ConditionOperatorTypes.NotEquals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesContent_WithUnknownOperator_ThrowsArgumentOutOfRangeException() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Content, + ConditionOperator = (ConditionOperatorTypes)999 + }; + + var act = () => _matcher.Matches(condition, comparisonItem); + + act.Should().Throw() + .WithMessage("*ConditionMatchesContent*"); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherDateTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherDateTests.cs new file mode 100644 index 000000000..5a2d18660 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherDateTests.cs @@ -0,0 +1,364 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherDateTests +{ + private DateConditionMatcher _matcher = null!; + + [SetUp] + public void SetUp() + { + var extractor = new ContentIdentityExtractor(); + _matcher = new DateConditionMatcher(extractor); + } + + [Test] + public void ConditionMatchesDate_Equals_WithSameDate_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var date = DateTime.UtcNow; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescriptionA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = date + }; + var fileDescriptionB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + LastWriteTimeUtc = date + }; + contentIdentity.Add(fileDescriptionA); + contentIdentity.Add(fileDescriptionB); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesDate_Equals_WithDifferentDate_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var dateA = DateTime.UtcNow; + var dateB = dateA.AddHours(1); + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescriptionA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }; + var fileDescriptionB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateB + }; + contentIdentity.Add(fileDescriptionA); + contentIdentity.Add(fileDescriptionB); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesDate_NotEquals_WithDifferentDate_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var dateA = DateTime.UtcNow; + var dateB = dateA.AddHours(1); + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescriptionA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }; + var fileDescriptionB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateB + }; + contentIdentity.Add(fileDescriptionA); + contentIdentity.Add(fileDescriptionB); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.NotEquals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesDate_IsNewerThan_WithNewerSource_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var dateA = DateTime.UtcNow; + var dateB = dateA.AddHours(-1); + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescriptionA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }; + var fileDescriptionB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateB + }; + contentIdentity.Add(fileDescriptionA); + contentIdentity.Add(fileDescriptionB); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.IsNewerThan + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesDate_IsOlderThan_WithOlderSource_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var dateA = DateTime.UtcNow.AddHours(-1); + var dateB = DateTime.UtcNow; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescriptionA = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }; + var fileDescriptionB = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateB + }; + contentIdentity.Add(fileDescriptionA); + contentIdentity.Add(fileDescriptionB); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.IsOlderThan + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesDate_WithNullSourceDate_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 100 }); + contentIdentityB.Add(new FileDescription + { + InventoryPart = partB, + RelativePath = "/file.txt", + LastWriteTimeUtc = DateTime.UtcNow + }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesDate_WithVirtualDestination_UsesDateTime() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + + var dateA = DateTime.UtcNow; + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + contentIdentityA.Add(new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + + var virtualDestination = new DataPart("A"); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = virtualDestination, + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.Equals, + DateTime = dateA + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesDate_WithVirtualDestination_TrimsSecondsAndMilliseconds() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + + var dateA = new DateTime(2024, 1, 1, 12, 30, 0, 0, DateTimeKind.Utc); + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + contentIdentityA.Add(new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + + var virtualDestination = new DataPart("A"); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = virtualDestination, + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.Equals, + DateTime = dateA + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesDate_IsNewerThan_WithVirtualDestinationNull_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + + var dateA = DateTime.UtcNow; + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + contentIdentityA.Add(new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = dateA + }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + + var virtualDestination = new DataPart("A"); + var olderDate = dateA.AddHours(-1); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = virtualDestination, + ComparisonProperty = ComparisonProperty.Date, + ConditionOperator = ConditionOperatorTypes.IsNewerThan, + DateTime = olderDate + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherLocalizeTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherLocalizeTests.cs new file mode 100644 index 000000000..57790253c --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherLocalizeTests.cs @@ -0,0 +1,202 @@ +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherLocalizeTests +{ + private ContentIdentityExtractor _extractor = null!; + + [SetUp] + public void SetUp() + { + _extractor = new ContentIdentityExtractor(); + } + + [Test] + public void LocalizeContentIdentity_WithInventory_ReturnsMatchingContentIdentity() + { + var inventoryA = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var inventoryB = new Inventory { InventoryId = "INV_B", Code = "B", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventoryA, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventoryB, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 200 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var dataPart = new DataPart("A", inventoryA); + + var result = _extractor.LocalizeContentIdentity(dataPart, comparisonItem); + + result.Should().Be(contentIdentityA); + } + + [Test] + public void LocalizeContentIdentity_WithInventoryPart_ReturnsMatchingContentIdentity() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 100 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 200 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var dataPart = new DataPart("A", partA); + + var result = _extractor.LocalizeContentIdentity(dataPart, comparisonItem); + + result.Should().Be(contentIdentityA); + } + + [Test] + public void LocalizeContentIdentity_WithNoMatch_ReturnsNull() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var otherPart = new InventoryPart(inventory, "c:/other", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = otherPart, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var dataPart = new DataPart("A", part); + + var result = _extractor.LocalizeContentIdentity(dataPart, comparisonItem); + + result.Should().BeNull(); + } + + [Test] + public void ExtractContentIdentity_WithNullDataPart_ReturnsNull() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _extractor.ExtractContentIdentity(null, comparisonItem); + + result.Should().BeNull(); + } + + [Test] + public void ExtractSize_WithContentIdentity_ReturnsSize() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 1024 }); + contentIdentity.Add(new FileDescription { InventoryPart = part, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var dataPart = new DataPart("A", part); + + var result = _extractor.ExtractSize(dataPart, comparisonItem); + + result.Should().Be(1024); + } + + [Test] + public void ExtractSize_WithNoContentIdentity_ReturnsNull() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var dataPart = new DataPart("A", part); + + var result = _extractor.ExtractSize(dataPart, comparisonItem); + + result.Should().BeNull(); + } + + [Test] + public void ExtractDate_WithContentIdentity_ReturnsLastWriteTime() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + + var date = DateTime.UtcNow; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescription = new FileDescription + { + InventoryPart = part, + RelativePath = "/file.txt", + LastWriteTimeUtc = date + }; + contentIdentity.Add(fileDescription); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var dataPart = new DataPart("A", part); + + var result = _extractor.ExtractDate(dataPart, comparisonItem); + + result.Should().Be(date); + } + + [Test] + public void ExtractDate_WithNoContentIdentity_ReturnsNull() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var dataPart = new DataPart("A", part); + + var result = _extractor.ExtractDate(dataPart, comparisonItem); + + result.Should().BeNull(); + } + + [Test] + public void ExtractDate_WithContentIdentityButNoMatchingInventoryPart_ReturnsNull() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var date = DateTime.UtcNow; + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + var fileDescription = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file.txt", + LastWriteTimeUtc = date + }; + contentIdentity.Add(fileDescription); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var dataPart = new DataPart("A", partB); + + var result = _extractor.ExtractDate(dataPart, comparisonItem); + + result.Should().BeNull(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherMakeMatchesTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherMakeMatchesTests.cs new file mode 100644 index 000000000..1a39c786a --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherMakeMatchesTests.cs @@ -0,0 +1,172 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Actions; +using ByteSync.Common.Business.Inventories; +using ByteSync.Interfaces.Controls.Comparisons; +using ByteSync.Interfaces.Repositories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherMakeMatchesTests +{ + private Mock _consistencyCheckerMock = null!; + private Mock _repositoryMock = null!; + private SynchronizationRuleMatcher _matcher = null!; + + [SetUp] + public void SetUp() + { + _consistencyCheckerMock = new Mock(); + _repositoryMock = new Mock(); + + var extractor = new ContentIdentityExtractor(); + var matchers = new IConditionMatcher[] + { + new ContentConditionMatcher(extractor), + new SizeConditionMatcher(extractor), + new DateConditionMatcher(extractor), + new PresenceConditionMatcher(extractor), + new NameConditionMatcher() + }; + var factory = new ConditionMatcherFactory(matchers); + + _matcher = new SynchronizationRuleMatcher(_consistencyCheckerMock.Object, _repositoryMock.Object, factory); + } + + [Test] + public void MakeMatches_WithSingleComparisonItem_CallsRepositoryAddOrUpdate() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var rules = new List(); + + _repositoryMock.Setup(r => r.GetAtomicActions(comparisonItem)).Returns([]); + _consistencyCheckerMock.Setup(c => c.GetApplicableActions(It.IsAny>())) + .Returns([]); + + _matcher.MakeMatches(comparisonItem, rules); + + _repositoryMock.Verify(r => r.AddOrUpdate(It.IsAny>()), Times.Once); + } + + [Test] + public void MakeMatches_WithCollectionOfComparisonItems_CallsRepositoryAddOrUpdateOnce() + { + var comparisonItems = new List + { + new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file1.txt", "file1.txt", "/file1.txt")), + new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file2.txt", "file2.txt", "/file2.txt")) + }; + var rules = new List(); + + _repositoryMock.Setup(r => r.GetAtomicActions(It.IsAny())).Returns([]); + _consistencyCheckerMock.Setup(c => c.GetApplicableActions(It.IsAny>())) + .Returns([]); + + _matcher.MakeMatches(comparisonItems, rules); + + _repositoryMock.Verify(r => r.AddOrUpdate(It.IsAny>()), Times.Once); + } + + [Test] + public void MakeMatches_RemovesExistingRuleActions_BeforeAddingNewOnes() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var rules = new List(); + + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var existingRuleAction = new AtomicAction { SynchronizationRule = rule }; + var existingNonRuleAction = new AtomicAction(); + + _repositoryMock.Setup(r => r.GetAtomicActions(comparisonItem)) + .Returns([existingRuleAction, existingNonRuleAction]); + _consistencyCheckerMock.Setup(c => c.GetApplicableActions(It.IsAny>())) + .Returns([]); + + _matcher.MakeMatches(comparisonItem, rules); + + _repositoryMock.Verify(r => r.Remove(It.Is>(actions => + actions.Count == 1 && actions.Contains(existingRuleAction))), Times.Once); + } + + [Test] + public void MakeMatches_WithMatchingRule_AddsActionsThatPassConsistencyCheck() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + rule.Conditions.Add(condition); + + var action1 = new AtomicAction { Operator = ActionOperatorTypes.DoNothing }; + var action2 = new AtomicAction { Operator = ActionOperatorTypes.Create }; + + _repositoryMock.Setup(r => r.GetAtomicActions(comparisonItem)).Returns([]); + _consistencyCheckerMock.Setup(c => c.GetApplicableActions(It.IsAny>())) + .Returns([action1, action2]); + _consistencyCheckerMock.Setup(c => + c.CheckCanAdd(It.Is(a => a.Operator == ActionOperatorTypes.DoNothing), comparisonItem)) + .Returns(new AtomicActionConsistencyCheckCanAddResult(new List { comparisonItem }) + { + ValidationResults = [new ComparisonItemValidationResult(comparisonItem, true)] + }); + _consistencyCheckerMock + .Setup(c => c.CheckCanAdd(It.Is(a => a.Operator == ActionOperatorTypes.Create), comparisonItem)) + .Returns(new AtomicActionConsistencyCheckCanAddResult(new List { comparisonItem }) + { + ValidationResults = + [ + new ComparisonItemValidationResult(comparisonItem, + AtomicActionValidationFailureReason.SourceNotAllowedForCreateOperation) + ] + }); + + _matcher.MakeMatches(comparisonItem, new List { rule }); + + _repositoryMock.Verify(r => r.AddOrUpdate(It.Is>(actions => + actions.Count == 1 && actions.Any(a => a.Operator == ActionOperatorTypes.DoNothing))), Times.Once); + } + + [Test] + public void MakeMatches_ClonesActionsBeforeCheckingConsistency() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var rule = new SynchronizationRule(FileSystemTypes.File, ConditionModes.All); + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Name, + ConditionOperator = ConditionOperatorTypes.Equals, + NamePattern = "file.txt" + }; + rule.Conditions.Add(condition); + + var originalAction = new AtomicAction { Operator = ActionOperatorTypes.DoNothing }; + originalAction.ComparisonItem = comparisonItem; + + _repositoryMock.Setup(r => r.GetAtomicActions(comparisonItem)).Returns([]); + _consistencyCheckerMock.Setup(c => c.GetApplicableActions(It.IsAny>())) + .Returns([originalAction]); + _consistencyCheckerMock.Setup(c => c.CheckCanAdd(It.IsAny(), comparisonItem)) + .Returns(new AtomicActionConsistencyCheckCanAddResult(new List { comparisonItem }) + { + ValidationResults = [new ComparisonItemValidationResult(comparisonItem, true)] + }); + + _matcher.MakeMatches(comparisonItem, new List { rule }); + + _repositoryMock.Verify(r => r.AddOrUpdate(It.Is>(actions => + actions.All(a => a.ComparisonItem == comparisonItem && a != originalAction))), Times.Once); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs index 8084581e6..9084c11fc 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherNameTests.cs @@ -1,14 +1,10 @@ -using System.Reflection; using ByteSync.Business.Actions.Local; using ByteSync.Business.Comparisons; using ByteSync.Business.Inventories; using ByteSync.Common.Business.Inventories; -using ByteSync.Interfaces.Controls.Comparisons; -using ByteSync.Interfaces.Repositories; using ByteSync.Models.Comparisons.Result; -using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; using FluentAssertions; -using Moq; using NUnit.Framework; namespace ByteSync.Client.UnitTests.Services.Comparisons; @@ -16,6 +12,14 @@ namespace ByteSync.Client.UnitTests.Services.Comparisons; [TestFixture] public class SynchronizationRuleMatcherNameTests { + private NameConditionMatcher _matcher = null!; + + [SetUp] + public void SetUp() + { + _matcher = new NameConditionMatcher(); + } + [TestCase("file.txt", "file.txt", ConditionOperatorTypes.Equals, true)] [TestCase("file.txt", "other.txt", ConditionOperatorTypes.Equals, false)] [TestCase("file.txt", "*.txt", ConditionOperatorTypes.Equals, true)] @@ -24,9 +28,6 @@ public class SynchronizationRuleMatcherNameTests [TestCase("file.txt", "*.doc", ConditionOperatorTypes.NotEquals, true)] public void ConditionMatchesName_ShouldBehaveAsExpected(string name, string pattern, ConditionOperatorTypes op, bool expected) { - var matcher = new SynchronizationRuleMatcher(new Mock().Object, - new Mock().Object); - var condition = new AtomicCondition { Source = new DataPart("A"), @@ -38,9 +39,7 @@ public void ConditionMatchesName_ShouldBehaveAsExpected(string name, string patt var pathIdentity = new PathIdentity(FileSystemTypes.File, name, name, name); var item = new ComparisonItem(pathIdentity); - var method = typeof(SynchronizationRuleMatcher) - .GetMethod("ConditionMatchesName", BindingFlags.NonPublic | BindingFlags.Instance)!; - var result = (bool)method.Invoke(matcher, new object[] { condition, item })!; + var result = _matcher.Matches(condition, item); result.Should().Be(expected); } } \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceExtendedTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceExtendedTests.cs new file mode 100644 index 000000000..6fa2419fa --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceExtendedTests.cs @@ -0,0 +1,178 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherPresenceExtendedTests +{ + private PresenceConditionMatcher _matcher = null!; + private ContentIdentityExtractor _extractor = null!; + + [SetUp] + public void SetUp() + { + _extractor = new ContentIdentityExtractor(); + _matcher = new PresenceConditionMatcher(_extractor); + } + + [Test] + public void ConditionMatchesPresence_ExistsOn_WithBothPresent_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentity.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Presence, + ConditionOperator = ConditionOperatorTypes.ExistsOn + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesPresence_ExistsOn_WithSourceNotPresent_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Presence, + ConditionOperator = ConditionOperatorTypes.ExistsOn + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesPresence_NotExistsOn_WithSourcePresentDestinationNotPresent_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Presence, + ConditionOperator = ConditionOperatorTypes.NotExistsOn + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesPresence_NotExistsOn_WithBothPresent_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 100 }); + contentIdentity.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentity.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Presence, + ConditionOperator = ConditionOperatorTypes.NotExistsOn + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesPresence_WithUnknownOperator_ThrowsArgumentOutOfRangeException() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + var condition = new AtomicCondition + { + Source = new DataPart("A"), + ComparisonProperty = ComparisonProperty.Presence, + ConditionOperator = (ConditionOperatorTypes)999 + }; + + var act = () => _matcher.Matches(condition, comparisonItem); + + act.Should().Throw() + .WithMessage("*ConditionMatchesPresence*"); + } + + [Test] + public void ExistsOn_WithNullDataPart_ReturnsFalse() + { + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + + var result = _extractor.ExistsOn(null, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ExistsOn_WithDirectory_ReturnsTrueWhenContentIdentityExists() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + var contentIdentity = new ContentIdentity(null); + contentIdentity.Add(new DirectoryDescription { InventoryPart = part, RelativePath = "/dir" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.Directory, "/dir", "dir", "/dir")); + comparisonItem.AddContentIdentity(contentIdentity); + + var dataPart = new DataPart("A", part); + + var result = _extractor.ExistsOn(dataPart, comparisonItem); + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs new file mode 100644 index 000000000..7587229d7 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs @@ -0,0 +1,49 @@ +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherPresenceTests +{ + [Test] + public void ExistsOn_File_ReturnsTrue_WhenContentIdentityHasInaccessibleDescription() + { + var extractor = new ContentIdentityExtractor(); + + // Build a comparison item for a file + var pathIdentity = new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt"); + var comparisonItem = new ComparisonItem(pathIdentity); + + // Inventory and part to associate + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var part = new InventoryPart(inventory, "c:/root", FileSystemTypes.Directory) { Code = "A1" }; + + // Content identity contains a file description marked as inaccessible + var ci = new ContentIdentity(null); + var fd = new FileDescription + { + InventoryPart = part, + RelativePath = "/file.txt", + FingerprintMode = null + }; + fd.IsAccessible = false; + ci.Add(fd); + + comparisonItem.AddContentIdentity(ci); + + // DataPart that points to the same inventory part + var dataPart = new DataPart("A", part); + + var result = extractor.ExistsOn(dataPart, comparisonItem); + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherSizeTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherSizeTests.cs new file mode 100644 index 000000000..200686433 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherSizeTests.cs @@ -0,0 +1,225 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +using ByteSync.Business.Inventories; +using ByteSync.Common.Business.Inventories; +using ByteSync.Common.Business.Misc; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +[TestFixture] +public class SynchronizationRuleMatcherSizeTests +{ + private SizeConditionMatcher _matcher = null!; + + [SetUp] + public void SetUp() + { + var extractor = new ContentIdentityExtractor(); + _matcher = new SizeConditionMatcher(extractor); + } + + [Test] + public void ConditionMatchesSize_Equals_WithSameSize_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hash1", Size = 1024 }); + contentIdentity.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentity.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentity); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesSize_Equals_WithDifferentSize_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 1024 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 2048 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesSize_NotEquals_WithDifferentSize_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 1024 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 2048 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.NotEquals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesSize_IsSmallerThan_WithSmallerSize_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 1024 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 2048 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.IsSmallerThan + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesSize_IsBiggerThan_WithBiggerSize_ReturnsTrue() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 2048 }); + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 1024 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.IsBiggerThan + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } + + [Test] + public void ConditionMatchesSize_WithNullSizeSource_ReturnsFalse() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + var partB = new InventoryPart(inventory, "c:/rootB", FileSystemTypes.Directory) { Code = "B1" }; + + var contentIdentityB = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashB", Size = 1024 }); + contentIdentityB.Add(new FileDescription { InventoryPart = partB, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityB); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = new DataPart("A", partB), + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.Equals + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeFalse(); + } + + [Test] + public void ConditionMatchesSize_WithVirtualDestination_UsesSizeAndSizeUnit() + { + var inventory = new Inventory { InventoryId = "INV_A", Code = "A", Endpoint = new(), MachineName = "M" }; + var partA = new InventoryPart(inventory, "c:/rootA", FileSystemTypes.Directory) { Code = "A1" }; + + var contentIdentityA = new ContentIdentity(new ContentIdentityCore { SignatureHash = "hashA", Size = 1024 }); + contentIdentityA.Add(new FileDescription { InventoryPart = partA, RelativePath = "/file.txt" }); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/file.txt", "file.txt", "/file.txt")); + comparisonItem.AddContentIdentity(contentIdentityA); + + var virtualDestination = new DataPart("A"); + + var condition = new AtomicCondition + { + Source = new DataPart("A", partA), + Destination = virtualDestination, + ComparisonProperty = ComparisonProperty.Size, + ConditionOperator = ConditionOperatorTypes.Equals, + Size = 1, + SizeUnit = SizeUnits.KB + }; + + var result = _matcher.Matches(condition, comparisonItem); + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherTestHelper.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherTestHelper.cs new file mode 100644 index 000000000..849de7987 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherTestHelper.cs @@ -0,0 +1,22 @@ +using ByteSync.Services.Comparisons; +using ByteSync.Services.Comparisons.ConditionMatchers; + +namespace ByteSync.Client.UnitTests.Services.Comparisons; + +public static class SynchronizationRuleMatcherTestHelper +{ + public static ConditionMatcherFactory CreateConditionMatcherFactory() + { + var extractor = new ContentIdentityExtractor(); + var matchers = new IConditionMatcher[] + { + new ContentConditionMatcher(extractor), + new SizeConditionMatcher(extractor), + new DateConditionMatcher(extractor), + new PresenceConditionMatcher(extractor), + new NameConditionMatcher() + }; + + return new ConditionMatcherFactory(matchers); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs new file mode 100644 index 000000000..a06d75f7c --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs @@ -0,0 +1,323 @@ +using ByteSync.Business; +using ByteSync.Business.DataNodes; +using ByteSync.Business.Inventories; +using ByteSync.Business.SessionMembers; +using ByteSync.Business.Sessions; +using ByteSync.Common.Business.EndPoints; +using ByteSync.Common.Business.Misc; +using ByteSync.Interfaces.Controls.Inventories; +using ByteSync.Services.Inventories; +using ByteSync.TestsCommon; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Inventories; + +public class InventoryBuilderInspectorTests : AbstractTester +{ + private readonly List _manualResetEvents = new(); + + [SetUp] + public void SetUp() + { + CreateTestDirectory(); + } + + [TearDown] + public void TearDown() + { + foreach (var mre in _manualResetEvents) + { + mre.Dispose(); + } + + _manualResetEvents.Clear(); + } + + private InventoryBuilder CreateBuilder(IFileSystemInspector inspector) + { + var endpoint = new ByteSyncEndpoint + { + ClientId = "client", + ClientInstanceId = Guid.NewGuid().ToString("N"), + Version = "1.0", + OSPlatform = OSPlatforms.Windows, + IpAddress = "127.0.0.1" + }; + + var sessionMember = new SessionMember + { + Endpoint = endpoint, + PrivateData = new SessionMemberPrivateData { MachineName = Environment.MachineName }, + SessionId = Guid.NewGuid().ToString("N"), + JoinedSessionOn = DateTimeOffset.UtcNow, + PositionInList = 0 + }; + + var dataNode = new DataNode + { Id = Guid.NewGuid().ToString("N"), ClientInstanceId = endpoint.ClientInstanceId, Code = "A", OrderIndex = 0 }; + var settings = SessionSettings.BuildDefault(); + var processData = new InventoryProcessData(); + + var logger = new Mock>().Object; + var analyzer = new Mock(); + analyzer.SetupAllProperties(); + var mre = new ManualResetEvent(false); + _manualResetEvents.Add(mre); + analyzer.Setup(a => a.HasFinished).Returns(mre); + var saver = new InventorySaver(); + var indexer = new Mock().Object; + + return new InventoryBuilder(sessionMember, dataNode, settings, processData, endpoint.OSPlatform, + FingerprintModes.Rsync, logger, analyzer.Object, saver, indexer, inspector); + } + + [Test] + public async Task Hidden_File_Is_Ignored() + { + var insp = new Mock(MockBehavior.Strict); + insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(true); + var builder = CreateBuilder(insp.Object); + + var filePath = Path.Combine(TestDirectory.FullName, "a.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().BeEmpty(); + } + + [Test] + public async Task System_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(true); + var builder = CreateBuilder(insp.Object); + + var filePath = Path.Combine(TestDirectory.FullName, "b.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv2.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().BeEmpty(); + } + + [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.IsReparsePoint(It.IsAny())).Returns(true); + var builder = CreateBuilder(insp.Object); + + var filePath = Path.Combine(TestDirectory.FullName, "c.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv3.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().BeEmpty(); + } + + [Test] + 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.IsReparsePoint(It.IsAny())).Returns(false); + insp.Setup(i => i.Exists(It.IsAny())).Returns(false); + insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); + insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); + var builder = CreateBuilder(insp.Object); + + var filePath = Path.Combine(TestDirectory.FullName, "d.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(filePath); + var invPath = Path.Combine(TestDirectory.FullName, "inv4.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().BeEmpty(); + } + + [Test] + public async Task UnauthorizedAccess_Adds_Inaccessible_FileDescription() + { + var insp = new Mock(MockBehavior.Strict); + + // Directory is readable + insp.Setup(i => i.IsHidden(It.IsAny(), It.IsAny())).Returns(false); + + // 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.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 = CreateBuilder(insp.Object); + + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root")); + var filePath = Path.Combine(root.FullName, "e.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv5.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().ContainSingle(); + var fd = part.FileDescriptions.Single(); + fd.IsAccessible.Should().BeFalse(); + fd.RelativePath.Should().Be("/e.txt"); + } + + [Test] + public async Task DirectoryNotFound_Adds_Inaccessible_FileDescription() + { + 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())) + .Throws(new DirectoryNotFoundException("parent missing")); + insp.Setup(i => i.IsSystem(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 = CreateBuilder(insp.Object); + + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_df")); + var filePath = Path.Combine(root.FullName, "df.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv_df.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().ContainSingle(); + var fd = part.FileDescriptions.Single(); + fd.IsAccessible.Should().BeFalse(); + fd.RelativePath.Should().Be("/df.txt"); + } + + [Test] + public async Task IOException_Adds_Inaccessible_FileDescription() + { + 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())) + .Throws(new IOException("io error")); + insp.Setup(i => i.IsSystem(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 = CreateBuilder(insp.Object); + + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root_io")); + var filePath = Path.Combine(root.FullName, "io.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv_io.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + part.FileDescriptions.Should().ContainSingle(); + var fd = part.FileDescriptions.Single(); + fd.IsAccessible.Should().BeFalse(); + fd.RelativePath.Should().Be("/io.txt"); + } + + [Test] + 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.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 = CreateBuilder(insp.Object); + + 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))) + .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"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv_dir_io.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + + // An inaccessible directory entry should exist for BadSub + part.DirectoryDescriptions.Any(d => d.RelativePath.EndsWith("/BadSub") && !d.IsAccessible).Should().BeTrue(); + + // Root file still processed + part.FileDescriptions.Any(f => f.RelativePath == "/ok.txt").Should().BeTrue(); + } + + [Test] + 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.Exists(It.IsAny())).Returns(true); + insp.Setup(i => i.IsOffline(It.IsAny())).Returns(false); + insp.Setup(i => i.IsRecallOnDataAccess(It.IsAny())).Returns(false); + + // Create directories before saving the Setup, which captures the path. + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root2")); + var sub = Directory.CreateDirectory(Path.Combine(root.FullName, "Sub")); + + // 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); + + var builder = CreateBuilder(insp.Object); + + var filePath = Path.Combine(root.FullName, "ok.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + builder.AddInventoryPart(root.FullName); + var invPath = Path.Combine(TestDirectory.FullName, "inv6.zip"); + await builder.BuildBaseInventoryAsync(invPath); + + var part = builder.Inventory.InventoryParts.Single(); + + // Ensure Sub directory is skipped due to reparse (no directory description for it) + part.DirectoryDescriptions.Any(d => d.RelativePath.EndsWith("/Sub")).Should().BeFalse(); + part.FileDescriptions.Should().ContainSingle(); + part.FileDescriptions[0].RelativePath.Should().Be("/ok.txt"); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderPublicTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderPublicTests.cs new file mode 100644 index 000000000..2826a4b08 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderPublicTests.cs @@ -0,0 +1,123 @@ +using ByteSync.Business; +using ByteSync.Business.DataNodes; +using ByteSync.Business.Inventories; +using ByteSync.Business.SessionMembers; +using ByteSync.Business.Sessions; +using ByteSync.Common.Business.EndPoints; +using ByteSync.Common.Business.Misc; +using ByteSync.Common.Business.Sessions; +using ByteSync.Interfaces.Controls.Inventories; +using ByteSync.Services.Inventories; +using ByteSync.TestsCommon; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Services.Inventories; + +public class InventoryBuilderPublicTests : AbstractTester +{ + private readonly List _manualResetEvents = new(); + + [SetUp] + public void SetUp() + { + CreateTestDirectory(); + } + + [TearDown] + public void TearDown() + { + foreach (var mre in _manualResetEvents) + { + mre.Dispose(); + } + + _manualResetEvents.Clear(); + } + + private InventoryBuilder CreateBuilder(OSPlatforms os = OSPlatforms.Windows) + { + var endpoint = new ByteSyncEndpoint + { + ClientId = "client", + ClientInstanceId = Guid.NewGuid().ToString("N"), + Version = "1.0", + OSPlatform = os, + IpAddress = "127.0.0.1" + }; + + var sessionMember = new SessionMember + { + Endpoint = endpoint, + PrivateData = new SessionMemberPrivateData { MachineName = Environment.MachineName }, + SessionId = Guid.NewGuid().ToString("N"), + JoinedSessionOn = DateTimeOffset.UtcNow, + PositionInList = 0, + SessionMemberGeneralStatus = SessionMemberGeneralStatus.InventoryWaitingForStart + }; + + var dataNode = new DataNode + { Id = Guid.NewGuid().ToString("N"), ClientInstanceId = endpoint.ClientInstanceId, Code = "A", OrderIndex = 0 }; + var settings = SessionSettings.BuildDefault(); + var processData = new InventoryProcessData(); + + var logger = new Mock>().Object; + var analyzer = new Mock(); + analyzer.SetupAllProperties(); + var mre = new ManualResetEvent(false); + _manualResetEvents.Add(mre); + analyzer.Setup(a => a.HasFinished).Returns(mre); + var saver = new InventorySaver(); + var indexer = new Mock().Object; + + return new InventoryBuilder(sessionMember, dataNode, settings, processData, os, FingerprintModes.Rsync, logger, + analyzer.Object, saver, indexer); + } + + [Test] + public async Task BuildBaseInventory_WithDeletedSingleFilePart_AddsInaccessibleFileDescription() + { + var builder = CreateBuilder(); + var work = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "w1")); + var filePath = Path.Combine(work.FullName, "gone.txt"); + await File.WriteAllTextAsync(filePath, "x"); + + // Add as a file inventory part, then delete before running analysis to trigger inaccessible handling + builder.AddInventoryPart(filePath); + Directory.Delete(work.FullName, recursive: true); // ensure directory not found + + var inventoryPath = Path.Combine(TestDirectory.FullName, "inv.zip"); + await builder.BuildBaseInventoryAsync(inventoryPath); + + File.Exists(inventoryPath).Should().BeTrue(); + var part = builder.Inventory.InventoryParts.Single(); + + // When the file path no longer exists, the builder currently skips it + part.FileDescriptions.Should().BeEmpty(); + } + + [Test] + public async Task BuildBaseInventory_WithDirectoryAndRegularFile_CoversNonReparsePaths() + { + var builder = CreateBuilder(); + var root = Directory.CreateDirectory(Path.Combine(TestDirectory.FullName, "root")); + var sub = Directory.CreateDirectory(Path.Combine(root.FullName, "Sub")); + var file = Path.Combine(sub.FullName, "file.txt"); + await File.WriteAllTextAsync(file, "x"); + + builder.AddInventoryPart(root.FullName); + var inventoryPath = Path.Combine(TestDirectory.FullName, "inv2.zip"); + await builder.BuildBaseInventoryAsync(inventoryPath); + + File.Exists(inventoryPath).Should().BeTrue(); + var part = builder.Inventory.InventoryParts.Single(); + + // Root directory gets registered, subdirectories are traversed but only registered on error + part.DirectoryDescriptions.Should().HaveCount(1); + part.FileDescriptions.Should().HaveCount(1); + part.FileDescriptions[0].RelativePath.Should().Be("/Sub/file.txt"); + part.FileDescriptions[0].IsAccessible.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModelTests.cs b/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModelTests.cs index 2998a65c0..48cec91b2 100644 --- a/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModelTests.cs +++ b/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModelTests.cs @@ -122,6 +122,7 @@ public void File_identity_without_hash_sets_empty_signature_and_regular_hash_ico ci, _inventory, _sessionService.Object, + _localizationService.Object, _factory.Object); civm.SignatureHash.Should().Be(""); @@ -147,6 +148,7 @@ public void File_identity_with_long_hash_is_truncated() ci, _inventory, _sessionService.Object, + _localizationService.Object, _factory.Object); vm.SignatureHash.Should().NotBeNull(); @@ -176,6 +178,7 @@ public void File_identity_with_analysis_error_sets_error_fields_and_tooltip_dela ci, _inventory, _sessionService.Object, + _localizationService.Object, _factory.Object); vm.HasAnalysisError.Should().BeTrue(); @@ -187,6 +190,34 @@ public void File_identity_with_analysis_error_sets_error_fields_and_tooltip_dela vm.ShowToolTipDelay.Should().Be(400); } + [Test] + public void File_identity_with_access_issue_sets_error_icon_and_tooltip_delay() + { + var ci = new ContentIdentity(null); + var file = new FileDescription + { + InventoryPart = _partA, + RelativePath = "/file.txt", + Size = 10, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow + }; + file.IsAccessible = false; + ci.Add(file); + + var vm = new ContentIdentityViewModel( + BuildComparisonItemViewModel(FileSystemTypes.File), + ci, + _inventory, + _sessionService.Object, + _localizationService.Object, + _factory.Object); + + vm.HasAnalysisError.Should().BeFalse(); + vm.HashOrWarnIcon.Should().Be("RegularError"); + vm.ShowToolTipDelay.Should().Be(400); + } + [Test] public void Directory_identity_sets_presence_parts_and_dates() { @@ -202,6 +233,7 @@ public void Directory_identity_sets_presence_parts_and_dates() ci, _inventory, _sessionService.Object, + _localizationService.Object, _factory.Object); vm.IsDirectory.Should().BeTrue(); diff --git a/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModelTests.cs b/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModelTests.cs new file mode 100644 index 000000000..d8d8becbe --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/Misc/ComparisonItemViewModelTests.cs @@ -0,0 +1,449 @@ +using System.Globalization; +using System.Reactive.Linq; +using ByteSync.Business; +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Inventories; +using ByteSync.Business.Sessions; +using ByteSync.Common.Business.EndPoints; +using ByteSync.Common.Business.Inventories; +using ByteSync.Common.Business.Misc; +using ByteSync.Common.Business.Sessions; +using ByteSync.Interfaces.Controls.Comparisons; +using ByteSync.Interfaces.Converters; +using ByteSync.Interfaces.Factories.ViewModels; +using ByteSync.Interfaces.Repositories; +using ByteSync.Interfaces.Services.Localizations; +using ByteSync.Interfaces.Services.Sessions; +using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; +using ByteSync.Models.Inventories; +using ByteSync.Repositories; +using ByteSync.ViewModels.Sessions.Comparisons.Results; +using ByteSync.ViewModels.Sessions.Comparisons.Results.Misc; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.ViewModels.Sessions.Comparisons.Results.Misc; + +[TestFixture] +public class ComparisonItemViewModelTests +{ + private Mock _targetedActionsService = null!; + private IAtomicActionRepository _atomicActionRepository = null!; + private Mock _contentIdentityViewModelFactory = null!; + private Mock _contentRepartitionViewModelFactory = null!; + private Mock _itemSynchronizationStatusViewModelFactory = null!; + private Mock _synchronizationActionViewModelFactory = null!; + private Mock _formatKbSizeConverter = null!; + private Mock _sessionService = null!; + private Mock _localizationService = null!; + private Mock _dateAndInventoryPartsViewModelFactory = null!; + + [SetUp] + public void Setup() + { + _targetedActionsService = new Mock(); + + _sessionService = new Mock(); + _sessionService.SetupGet(s => s.SessionObservable).Returns(Observable.Never()); + _sessionService.SetupGet(s => s.SessionStatusObservable).Returns(Observable.Never()); + _sessionService.SetupGet(s => s.CurrentSessionSettings).Returns(new SessionSettings + { + DataType = DataTypes.Files, + MatchingMode = MatchingModes.Flat, + LinkingCase = LinkingCases.Insensitive + }); + _sessionService.SetupGet(s => s.IsCloudSession).Returns(false); + + _atomicActionRepository = new AtomicActionRepository( + new SessionInvalidationCachePolicy(_sessionService.Object), + new PropertyIndexer()); + + var culture = new CultureDefinition(CultureInfo.InvariantCulture); + _localizationService = new Mock(); + _localizationService.SetupGet(l => l.CurrentCultureDefinition).Returns(culture); + _localizationService.SetupGet(l => l.CurrentCultureObservable).Returns(Observable.Never()); + + _dateAndInventoryPartsViewModelFactory = new Mock(); + _dateAndInventoryPartsViewModelFactory.Setup(f => + f.CreateDateAndInventoryPartsViewModel(It.IsAny(), It.IsAny(), + It.IsAny>())) + .Returns((ContentIdentityViewModel civm, DateTime dt, HashSet parts) => + new DateAndInventoryPartsViewModel(civm, dt, parts, _sessionService.Object, _localizationService.Object)); + + _contentIdentityViewModelFactory = new Mock(); + _contentRepartitionViewModelFactory = new Mock(); + _contentRepartitionViewModelFactory + .Setup(f => f.CreateContentRepartitionViewModel(It.IsAny(), It.IsAny>())) + .Returns(new ContentRepartitionViewModel()); + + _itemSynchronizationStatusViewModelFactory = new Mock(); + _itemSynchronizationStatusViewModelFactory + .Setup(f => f.CreateItemSynchronizationStatusViewModel(It.IsAny(), It.IsAny>())) + .Returns(new ItemSynchronizationStatusViewModel()); + + _synchronizationActionViewModelFactory = new Mock(); + _formatKbSizeConverter = new Mock(); + } + + private ComparisonItemViewModel CreateViewModel(ComparisonItem comparisonItem, List inventories) + { + return new ComparisonItemViewModel( + _targetedActionsService.Object, + _atomicActionRepository, + _contentIdentityViewModelFactory.Object, + _contentRepartitionViewModelFactory.Object, + _itemSynchronizationStatusViewModelFactory.Object, + comparisonItem, + inventories, + _synchronizationActionViewModelFactory.Object, + _formatKbSizeConverter.Object); + } + + private static Inventory CreateInventory(string code, string inventoryId, string machineName) + { + return new Inventory + { + InventoryId = inventoryId, + Code = code, + MachineName = machineName, + Endpoint = new ByteSyncEndpoint + { + ClientInstanceId = $"CII_{code}", + OSPlatform = OSPlatforms.Windows + } + }; + } + + private static InventoryPart CreateInventoryPart(Inventory inventory, string rootPath, string code) + { + return new InventoryPart(inventory, rootPath, FileSystemTypes.Directory) { Code = code }; + } + + [Test] + public void Constructor_WithContentIdentities_CreatesContentIdentityViewModelsForEachInventory() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventories = new List { inventoryA, inventoryB }; + + var partA = CreateInventoryPart(inventoryA, "c:/rootA", "A1"); + var partB = CreateInventoryPart(inventoryB, "c:/rootB", "B1"); + + var contentIdentity1 = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file1 = new FileDescription + { + InventoryPart = partA, + RelativePath = "/file1.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity1.Add(file1); + + var contentIdentity2 = new ContentIdentity(new ContentIdentityCore { Size = 200 }); + var file2 = new FileDescription + { + InventoryPart = partB, + RelativePath = "/file2.txt", + Size = 200, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity2.Add(file2); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + comparisonItem.AddContentIdentity(contentIdentity1); + comparisonItem.AddContentIdentity(contentIdentity2); + + var createdViewModels = new List(); + _contentIdentityViewModelFactory + .Setup(f => f.CreateContentIdentityViewModel(It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns((ComparisonItemViewModel parent, ContentIdentity ci, Inventory inv) => + { + var vm = new ContentIdentityViewModel(parent, ci, inv, _sessionService.Object, _localizationService.Object, + _dateAndInventoryPartsViewModelFactory.Object); + createdViewModels.Add(vm); + + return vm; + }); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + createdViewModels.Should().HaveCount(2); + viewModel.ContentIdentitiesA.Should().HaveCount(1); + viewModel.ContentIdentitiesB.Should().HaveCount(1); + } + + [Test] + public void Constructor_WithContentIdentityInMultipleInventories_CreatesViewModelsForEachInventory() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventories = new List { inventoryA, inventoryB }; + + var partA1 = CreateInventoryPart(inventoryA, "c:/rootA1", "A1"); + var partA2 = CreateInventoryPart(inventoryA, "c:/rootA2", "A2"); + var partB1 = CreateInventoryPart(inventoryB, "c:/rootB1", "B1"); + + var contentIdentity = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file1 = new FileDescription + { + InventoryPart = partA1, + RelativePath = "/file1.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity.Add(file1); + + var file2 = new FileDescription + { + InventoryPart = partA2, + RelativePath = "/file2.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity.Add(file2); + + var file3 = new FileDescription + { + InventoryPart = partB1, + RelativePath = "/file3.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity.Add(file3); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + comparisonItem.AddContentIdentity(contentIdentity); + + var createdViewModels = new List(); + _contentIdentityViewModelFactory + .Setup(f => f.CreateContentIdentityViewModel(It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns((ComparisonItemViewModel parent, ContentIdentity ci, Inventory inv) => + { + var vm = new ContentIdentityViewModel(parent, ci, inv, _sessionService.Object, _localizationService.Object, + _dateAndInventoryPartsViewModelFactory.Object); + createdViewModels.Add(vm); + + return vm; + }); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + createdViewModels.Should().HaveCount(2); + viewModel.ContentIdentitiesA.Should().HaveCount(1); + viewModel.ContentIdentitiesB.Should().HaveCount(1); + } + + [Test] + public void Constructor_SortsContentIdentitiesByInventoryPartCodes() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventories = new List { inventoryA }; + + var partA2 = CreateInventoryPart(inventoryA, "c:/rootA2", "A2"); + var partA1 = CreateInventoryPart(inventoryA, "c:/rootA1", "A1"); + var partA3 = CreateInventoryPart(inventoryA, "c:/rootA3", "A3"); + + var contentIdentity1 = new ContentIdentity(new ContentIdentityCore { Size = 100 }); + var file1 = new FileDescription + { + InventoryPart = partA2, + RelativePath = "/file2.txt", + Size = 100, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity1.Add(file1); + + var contentIdentity2 = new ContentIdentity(new ContentIdentityCore { Size = 200 }); + var file2 = new FileDescription + { + InventoryPart = partA1, + RelativePath = "/file1.txt", + Size = 200, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity2.Add(file2); + + var contentIdentity3 = new ContentIdentity(new ContentIdentityCore { Size = 300 }); + var file3 = new FileDescription + { + InventoryPart = partA3, + RelativePath = "/file3.txt", + Size = 300, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + IsAccessible = true + }; + contentIdentity3.Add(file3); + + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + comparisonItem.AddContentIdentity(contentIdentity1); + comparisonItem.AddContentIdentity(contentIdentity2); + comparisonItem.AddContentIdentity(contentIdentity3); + + var createdViewModels = new Dictionary(); + _contentIdentityViewModelFactory + .Setup(f => f.CreateContentIdentityViewModel(It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns((ComparisonItemViewModel parent, ContentIdentity ci, Inventory inv) => + { + var vm = new ContentIdentityViewModel(parent, ci, inv, _sessionService.Object, _localizationService.Object, + _dateAndInventoryPartsViewModelFactory.Object); + createdViewModels[ci] = vm; + + return vm; + }); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + viewModel.ContentIdentitiesA.Should().HaveCount(3); + var sortedCodes = viewModel.ContentIdentitiesA + .Select(vm => vm.ContentIdentity.GetInventoryParts() + .Where(ip => ip.Inventory.Equals(inventoryA)) + .Min(ip => ip.Code)) + .ToList(); + + sortedCodes.Should().BeInAscendingOrder(); + sortedCodes.Should().Equal("A1", "A2", "A3"); + + createdViewModels.Should().HaveCount(3); + createdViewModels.Keys.Should().BeEquivalentTo([contentIdentity1, contentIdentity2, contentIdentity3]); + foreach (var vm in createdViewModels.Values) + { + viewModel.ContentIdentitiesA.Should().Contain(vm); + } + } + + [Test] + public void GetContentIdentityViews_WithIndex0_ReturnsContentIdentitiesA() + { + var inventory = CreateInventory("A", "INV_A", "MachineA"); + var inventories = new List { inventory }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + var result = viewModel.GetContentIdentityViews(inventory); + + result.Should().BeSameAs(viewModel.ContentIdentitiesA); + } + + [Test] + public void GetContentIdentityViews_WithIndex1_ReturnsContentIdentitiesB() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventories = new List { inventoryA, inventoryB }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + var result = viewModel.GetContentIdentityViews(inventoryB); + + result.Should().BeSameAs(viewModel.ContentIdentitiesB); + } + + [Test] + public void GetContentIdentityViews_WithIndex2_ReturnsContentIdentitiesC() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventoryC = CreateInventory("C", "INV_C", "MachineC"); + var inventories = new List { inventoryA, inventoryB, inventoryC }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + var result = viewModel.GetContentIdentityViews(inventoryC); + + result.Should().BeSameAs(viewModel.ContentIdentitiesC); + } + + [Test] + public void GetContentIdentityViews_WithIndex3_ReturnsContentIdentitiesD() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventoryC = CreateInventory("C", "INV_C", "MachineC"); + var inventoryD = CreateInventory("D", "INV_D", "MachineD"); + var inventories = new List { inventoryA, inventoryB, inventoryC, inventoryD }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + var result = viewModel.GetContentIdentityViews(inventoryD); + + result.Should().BeSameAs(viewModel.ContentIdentitiesD); + } + + [Test] + public void GetContentIdentityViews_WithIndex4_ReturnsContentIdentitiesE() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventoryC = CreateInventory("C", "INV_C", "MachineC"); + var inventoryD = CreateInventory("D", "INV_D", "MachineD"); + var inventoryE = CreateInventory("E", "INV_E", "MachineE"); + var inventories = new List { inventoryA, inventoryB, inventoryC, inventoryD, inventoryE }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + var result = viewModel.GetContentIdentityViews(inventoryE); + + result.Should().BeSameAs(viewModel.ContentIdentitiesE); + } + + [Test] + public void GetContentIdentityViews_WithInvalidIndex_ThrowsApplicationException() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventoryC = CreateInventory("C", "INV_C", "MachineC"); + var inventoryD = CreateInventory("D", "INV_D", "MachineD"); + var inventoryE = CreateInventory("E", "INV_E", "MachineE"); + var inventoryF = CreateInventory("F", "INV_F", "MachineF"); + var inventories = new List { inventoryA, inventoryB, inventoryC, inventoryD, inventoryE }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + Action act = () => viewModel.GetContentIdentityViews(inventoryF); + + act.Should().Throw() + .WithMessage("GetContentIdentityViews: can not identify ContentIdentities, -1:*"); + } + + [Test] + public void GetContentIdentityViews_WithInventoryNotInList_ThrowsApplicationException() + { + var inventoryA = CreateInventory("A", "INV_A", "MachineA"); + var inventoryB = CreateInventory("B", "INV_B", "MachineB"); + var inventories = new List { inventoryA }; + var comparisonItem = new ComparisonItem(new PathIdentity(FileSystemTypes.File, "/p", "name", "/p")); + + var viewModel = CreateViewModel(comparisonItem, inventories); + + Action act = () => viewModel.GetContentIdentityViews(inventoryB); + + act.Should().Throw() + .WithMessage("GetContentIdentityViews: can not identify ContentIdentities, -1:*"); + } +} \ No newline at end of file