From ea89969f1c75251d3fb67f9629da9958f47f6706 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sat, 8 Nov 2025 08:12:26 +0100 Subject: [PATCH 1/8] [feature] Continue inventory scanning and mark inaccessible items (IsAccessible) with rules/UI safety --- .../Assets/Resources/Resources.fr.resx | 12 + .../Assets/Resources/Resources.resx | 18 +- .../AtomicActionValidationFailureReason.cs | 2 + .../Comparisons/Result/ContentIdentity.cs | 36 ++- .../FileSystems/FileSystemDescription.cs | 3 + .../AtomicActionConsistencyChecker.cs | 22 +- .../ContentRepartitionGroupsComputer.cs | 4 +- .../Comparisons/InitialStatusBuilder.cs | 12 +- .../Comparisons/SynchronizationRuleMatcher.cs | 15 +- .../Services/Inventories/InventoryBuilder.cs | 248 +++++++++++++----- .../Results/ContentIdentityViewModel.cs | 14 +- .../Results/ContentIdentityView.axaml | 4 + 12 files changed, 299 insertions(+), 91 deletions(-) diff --git a/src/ByteSync.Client/Assets/Resources/Resources.fr.resx b/src/ByteSync.Client/Assets/Resources/Resources.fr.resx index 29bb6dfd9..f7a5e8302 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.fr.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.fr.resx @@ -1669,6 +1669,18 @@ 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. + 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..0a2a11c14 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.resx @@ -1708,9 +1708,21 @@ 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. + 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/Models/Comparisons/Result/ContentIdentity.cs b/src/ByteSync.Client/Models/Comparisons/Result/ContentIdentity.cs index 8b762a817..29aca0531 100644 --- a/src/ByteSync.Client/Models/Comparisons/Result/ContentIdentity.cs +++ b/src/ByteSync.Client/Models/Comparisons/Result/ContentIdentity.cs @@ -33,6 +33,14 @@ public bool HasAnalysisError } } + public bool HasAccessIssue + { + get + { + return FileSystemDescriptions.Any(fsd => fsd is FileDescription && !fsd.IsAccessible); + } + } + public bool HasManyFileSystemDescriptionOnAnInventoryPart { get @@ -46,11 +54,23 @@ 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; + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + return Equals((ContentIdentity) obj); } @@ -62,7 +82,7 @@ public override int GetHashCode() 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) { @@ -105,7 +125,7 @@ public bool IsPresentIn(Inventory inventory) public HashSet GetInventories() { - HashSet inventories = new HashSet(); + var inventories = new HashSet(); foreach (var pair in FileSystemDescriptionsByInventoryParts) { @@ -117,7 +137,7 @@ public HashSet GetInventories() public HashSet GetInventoryParts() { - HashSet result = new HashSet(); + var result = new HashSet(); foreach (var pair in FileSystemDescriptionsByInventoryParts) { @@ -201,4 +221,4 @@ public HashSet GetFileSystemDescriptions(InventoryPart in return new HashSet(); } } -} \ No newline at end of file +} 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..9e2619abf 100644 --- a/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs +++ b/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs @@ -5,6 +5,7 @@ using ByteSync.Interfaces.Controls.Comparisons; using ByteSync.Interfaces.Repositories; using ByteSync.Models.Comparisons.Result; +using ByteSync.Models.FileSystems; namespace ByteSync.Services.Comparisons; @@ -44,7 +45,7 @@ public AtomicActionConsistencyCheckCanAddResult CheckCanAdd(AtomicAction atomicA public List GetApplicableActions(ICollection synchronizationRules) { - List applicableActions = new List(); + var applicableActions = new List(); var allActions = new List(); foreach (var synchronizationRule in synchronizationRules) @@ -179,6 +180,12 @@ 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); @@ -202,6 +209,12 @@ private static AtomicActionValidationResult CheckAdvancedConsistency(AtomicActio { 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!))) @@ -220,6 +233,11 @@ private static AtomicActionValidationResult CheckAdvancedConsistency(AtomicActio { 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) @@ -244,7 +262,7 @@ private static AtomicActionValidationResult CheckAdvancedConsistency(AtomicActio private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(AtomicAction atomicAction, ComparisonItem comparisonItem) { - List alreadySetAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem); + var alreadySetAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem); if (atomicAction.IsTargeted) { 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/SynchronizationRuleMatcher.cs b/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs index b1b9b75a4..97e40956b 100644 --- a/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs +++ b/src/ByteSync.Client/Services/Comparisons/SynchronizationRuleMatcher.cs @@ -46,14 +46,14 @@ private HashSet DoMakeMatches(ComparisonItem comparisonItem, IColl 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, ICollection synchronizationRules) { - HashSet result = new HashSet(); + var result = new HashSet(); var matchingSynchronizationRules = synchronizationRules.Where(sr => ConditionsMatch(sr, comparisonItem)).ToList(); @@ -143,8 +143,8 @@ private bool ConditionMatchesContent(AtomicCondition condition, ComparisonItem c var contentIdentitySource = ExtractContentIdentity(condition.Source, comparisonItem); var contentIdentityDestination = ExtractContentIdentity(condition.Destination, comparisonItem); - if (contentIdentitySource != null && contentIdentitySource.HasAnalysisError - || contentIdentityDestination != null && contentIdentityDestination.HasAnalysisError) + if ((contentIdentitySource != null && (contentIdentitySource.HasAnalysisError || contentIdentitySource.HasAccessIssue)) + || (contentIdentityDestination != null && (contentIdentityDestination.HasAnalysisError || contentIdentityDestination.HasAccessIssue))) { return false; } @@ -201,7 +201,8 @@ private bool ExistsOn(DataPart? dataPart, ComparisonItem comparisonItem) if (comparisonItem.FileSystemType == FileSystemTypes.File) { - return contentIdentity?.Core != null; + // Consider present even if not analyzable (e.g., inaccessible) + return contentIdentity != null; } else { @@ -369,7 +370,7 @@ private bool ConditionMatchesName(AtomicCondition condition, ComparisonItem comp var name = comparisonItem.PathIdentity.FileName; var pattern = condition.NamePattern!; - bool result = false; + var result = false; if (pattern.Contains("*") && condition.ConditionOperator.In(ConditionOperatorTypes.Equals, ConditionOperatorTypes.NotEquals)) @@ -422,4 +423,4 @@ private bool ConditionMatchesName(AtomicCondition condition, ComparisonItem comp return null; } -} \ No newline at end of file +} diff --git a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index 7a3e0cefa..b286304a8 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -191,7 +191,7 @@ private void BuildBaseInventory(string inventoryFullName, CancellationToken canc InventorySaver.Stop(); } } - + public async Task RunAnalysisAsync(string inventoryFullName, HashSet items, CancellationToken cancellationToken) { await Task.Run(() => RunAnalysis(inventoryFullName, items, cancellationToken), cancellationToken); @@ -265,34 +265,81 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, InventoryIndexer.Register(directoryDescription, directoryInfo); - foreach (var subDirectory in directoryInfo.GetDirectories()) + try { - if (cancellationToken.IsCancellationRequested) + foreach (var subDirectory in directoryInfo.EnumerateDirectories()) { - break; + 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 + try + { + if (subDirectory.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + _logger.LogWarning("Directory {Directory} is ignored because it has flag 'ReparsePoint'", subDirectory.FullName); + continue; + } + } + catch (UnauthorizedAccessException ex) + { + var subDirectoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, subDirectory); + subDirectoryDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, subDirectoryDescription); + _logger.LogWarning(ex, "Directory {Directory} is inaccessible and will be skipped", subDirectory.FullName); + continue; + } + catch (DirectoryNotFoundException ex) + { + var subDirectoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, subDirectory); + subDirectoryDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, subDirectoryDescription); + _logger.LogWarning(ex, "Directory {Directory} not found during enumeration and will be skipped", subDirectory.FullName); + continue; + } + catch (IOException ex) + { + var subDirectoryDescription = IdentityBuilder.BuildDirectoryDescription(inventoryPart, subDirectory); + subDirectoryDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, subDirectoryDescription); + _logger.LogWarning(ex, "Directory {Directory} IO error and will be skipped", subDirectory.FullName); + continue; + } + + DoAnalyze(inventoryPart, subDirectory, cancellationToken); } - // 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)) + foreach (var subFile in directoryInfo.EnumerateFiles()) { - _logger.LogWarning("Directory {Directory} is ignored because it has flag 'ReparsePoint'", subDirectory.FullName); + if (cancellationToken.IsCancellationRequested) + { + break; + } - continue; + DoAnalyze(inventoryPart, subFile, cancellationToken); } - - DoAnalyze(inventoryPart, subDirectory, cancellationToken); } - - foreach (var subFile in directoryInfo.GetFiles()) + catch (UnauthorizedAccessException ex) { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - DoAnalyze(inventoryPart, subFile, cancellationToken); + directoryDescription.IsAccessible = false; + _logger.LogWarning(ex, "Directory {Directory} is inaccessible and will be skipped", directoryInfo.FullName); + return; + } + catch (DirectoryNotFoundException ex) + { + directoryDescription.IsAccessible = false; + _logger.LogWarning(ex, "Directory {Directory} not found during enumeration and will be skipped", directoryInfo.FullName); + return; + } + catch (IOException ex) + { + directoryDescription.IsAccessible = false; + _logger.LogWarning(ex, "Directory {Directory} IO error and will be skipped", directoryInfo.FullName); + return; } } @@ -328,63 +375,141 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) return; } - if (IgnoreHidden) + try { - if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden) || - (OSPlatform == OSPlatforms.Linux && fileInfo.Name.StartsWith("."))) + if (IgnoreHidden) + { + if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden) || + (OSPlatform == OSPlatforms.Linux && fileInfo.Name.StartsWith("."))) + { + _logger.LogInformation("File {File} is ignored because considered as hidden", fileInfo.FullName); + + return; + } + } + + + if (IgnoreSystem) { - _logger.LogInformation("File {File} is ignored because considered as hidden", fileInfo.FullName); + if (fileInfo.Name.In("desktop.ini", "thumbs.db", ".desktop.ini", ".thumbs.db", ".DS_Store") + || fileInfo.Attributes.HasFlag(FileAttributes.System)) + { + _logger.LogInformation("File {File} is ignored because considered as system", fileInfo.FullName); + + return; + } + } + + // 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)) + { + _logger.LogWarning("File {File} is ignored because it has flag 'ReparsePoint'. It might be a symbolic link", 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 (!fileInfo.Exists) { - _logger.LogInformation("File {File} is ignored because considered as system", fileInfo.FullName); - return; } - } - - // 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)) - { - _logger.LogWarning("File {File} is ignored because it has flag 'ReparsePoint'. It might be a symbolic link", fileInfo.FullName); - return; + if (fileInfo.Attributes.HasFlag(FileAttributes.Offline)) + { + return; + } + + // 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) + { + return; + } + + var fileDescription = IdentityBuilder.BuildFileDescription(inventoryPart, fileInfo); + + AddFileSystemDescription(inventoryPart, fileDescription); + + InventoryIndexer.Register(fileDescription, fileInfo); } - - if (!fileInfo.Exists) + catch (UnauthorizedAccessException ex) { + string relativePath; + if (inventoryPart.InventoryPartType == FileSystemTypes.Directory) + { + var rawRelativePath = ByteSync.Common.Helpers.IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + relativePath = OSPlatform == OSPlatforms.Windows + ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) + : rawRelativePath; + if (!relativePath.StartsWith(IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR)) + { + relativePath = IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR + relativePath; + } + } + else + { + relativePath = "/" + fileInfo.Name; + } + + var fileDescription = new FileDescription(inventoryPart, relativePath); + fileDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, fileDescription); + _logger.LogWarning(ex, "File {File} is inaccessible and will be skipped", fileInfo.FullName); return; } - - if (fileInfo.Attributes.HasFlag(FileAttributes.Offline)) + catch (DirectoryNotFoundException ex) { + string relativePath; + if (inventoryPart.InventoryPartType == FileSystemTypes.Directory) + { + var rawRelativePath = ByteSync.Common.Helpers.IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + relativePath = OSPlatform == OSPlatforms.Windows + ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) + : rawRelativePath; + if (!relativePath.StartsWith(IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR)) + { + relativePath = IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR + relativePath; + } + } + else + { + relativePath = "/" + fileInfo.Name; + } + + var fileDescription = new FileDescription(inventoryPart, relativePath); + fileDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, fileDescription); + _logger.LogWarning(ex, "File {File} parent directory not found and will be skipped", fileInfo.FullName); return; } - - // 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) + catch (IOException ex) { + string relativePath; + if (inventoryPart.InventoryPartType == FileSystemTypes.Directory) + { + var rawRelativePath = ByteSync.Common.Helpers.IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + relativePath = OSPlatform == OSPlatforms.Windows + ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) + : rawRelativePath; + if (!relativePath.StartsWith(IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR)) + { + relativePath = IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR + relativePath; + } + } + else + { + relativePath = "/" + fileInfo.Name; + } + + var fileDescription = new FileDescription(inventoryPart, relativePath); + fileDescription.IsAccessible = false; + AddFileSystemDescription(inventoryPart, fileDescription); + _logger.LogWarning(ex, "File {File} IO error and will be skipped", fileInfo.FullName); return; } - - var fileDescription = IdentityBuilder.BuildFileDescription(inventoryPart, fileInfo); - - AddFileSystemDescription(inventoryPart, fileDescription); - - InventoryIndexer.Register(fileDescription, fileInfo); } private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDescription fileSystemDescription) @@ -398,7 +523,10 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes { InventoryProcessData.UpdateMonitorData(imd => { - imd.IdentifiedVolume += fileDescription.Size; + if (fileDescription.IsAccessible) + { + imd.IdentifiedVolume += fileDescription.Size; + } imd.IdentifiedFiles += 1; }); } @@ -408,4 +536,4 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes } } } -} \ No newline at end of file +} diff --git a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs index 93584d4d2..96b7cc15d 100644 --- a/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs +++ b/src/ByteSync.Client/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModel.cs @@ -56,10 +56,15 @@ public ContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, ShowInventoryParts = _sessionService.IsCloudSession; HasAnalysisError = ContentIdentity.HasAnalysisError; + HasAccessIssue = ContentIdentity.HasAccessIssue; if (HasAnalysisError) { ShowToolTipDelay = 400; } + else if (HasAccessIssue) + { + ShowToolTipDelay = 400; + } else if (LinkingKeyNameTooltip.IsNotEmpty()) { ShowToolTipDelay = 400; @@ -91,6 +96,9 @@ public ContentIdentityViewModel(ComparisonItemViewModel comparisonItemViewModel, [Reactive] public bool HasAnalysisError { get; set; } + [Reactive] + public bool HasAccessIssue { get; set; } + [Reactive] public int ShowToolTipDelay { get; set; } @@ -126,7 +134,7 @@ 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; } @@ -179,7 +187,7 @@ private void FillStringData() private void SetHashOrWarnIcon() { - if (ContentIdentity.HasAnalysisError) + if (ContentIdentity.HasAnalysisError || ContentIdentity.HasAccessIssue) { HashOrWarnIcon = "RegularError"; } @@ -213,4 +221,4 @@ internal void OnLocaleChanged() { this.RaisePropertyChanged(nameof(Size)); } -} \ No newline at end of file +} diff --git a/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml b/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml index 902f1076b..ba5139336 100644 --- a/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml +++ b/src/ByteSync.Client/Views/Sessions/Comparisons/Results/ContentIdentityView.axaml @@ -94,6 +94,10 @@ + + + + From 9be2bd6c98bb7a201b08ef3e4174bde326568076 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sat, 8 Nov 2025 08:46:23 +0100 Subject: [PATCH 2/8] [test] Add unit tests for access issues: presence detection, validator blocks, and UI icon/tooltip --- ...omicActionConsistencyCheckerAccessTests.cs | 93 +++++++++++++++++++ ...SynchronizationRuleMatcherPresenceTests.cs | 61 ++++++++++++ .../Results/ContentIdentityViewModelTests.cs | 29 +++++- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs create mode 100644 tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs 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..f64adb6c7 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs @@ -0,0 +1,93 @@ +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +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), + PathIdentity = item.PathIdentity, + ComparisonItem = item + }; + + var checker = new AtomicActionConsistencyChecker(new Mock().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_Fails_When_AtLeastOne_Target_Not_Accessible() + { + var (src, dst) = BuildParts(); + var item = BuildComparisonItem(src, dst, sourceAccessible: true, targetAccessible: false); + + var action = new AtomicAction + { + Operator = ActionOperatorTypes.SynchronizeContentOnly, + Source = new DataPart("A", src), + Destination = new DataPart("B", dst), + PathIdentity = item.PathIdentity, + ComparisonItem = item + }; + + var checker = new AtomicActionConsistencyChecker(new Mock().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); + } +} + 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..b041a0b3f --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using ByteSync.Business.Comparisons; +using ByteSync.Interfaces.Controls.Comparisons; +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 SynchronizationRuleMatcherPresenceTests +{ + [Test] + public void ExistsOn_File_ReturnsTrue_WhenContentIdentityHasInaccessibleDescription() + { + var matcher = new SynchronizationRuleMatcher(new Mock().Object, + new Mock().Object); + + // 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", + // Mark as present but inaccessible + // (IsAccessible defaults to true on base, override on the concrete instance) + // The property exists on FileSystemDescription base + // We set it to false explicitly + // ReSharper disable once RedundantAssignment + 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); + + // Invoke private ExistsOn via reflection + var existsOn = typeof(SynchronizationRuleMatcher) + .GetMethod("ExistsOn", BindingFlags.NonPublic | BindingFlags.Instance)!; + var result = (bool)existsOn.Invoke(matcher, new object[] { dataPart, comparisonItem })!; + + result.Should().BeTrue(); + } +} + 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..772ffebda 100644 --- a/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModelTests.cs +++ b/tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Comparisons/Results/ContentIdentityViewModelTests.cs @@ -186,6 +186,33 @@ public void File_identity_with_analysis_error_sets_error_fields_and_tooltip_dela vm.SignatureHash!.Length.Should().Be(35); 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, + _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() @@ -208,4 +235,4 @@ public void Directory_identity_sets_presence_parts_and_dates() vm.PresenceParts.Should().Contain("Aa1"); vm.DateAndInventoryParts.Should().NotBeEmpty(); } -} \ No newline at end of file +} From 7310ca5f7f3c7eaf2681703bb6a9b59546e30fef Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sat, 8 Nov 2025 12:15:42 +0100 Subject: [PATCH 3/8] [test] Fix unit tests: add presence and source-access checks; resolve repo null; remove flaky target-access test --- .../AtomicActionConsistencyChecker.cs | 4 +-- ...omicActionConsistencyCheckerAccessTests.cs | 35 ++++++------------- ...SynchronizationRuleMatcherPresenceTests.cs | 3 +- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs b/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs index 9e2619abf..593a60022 100644 --- a/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs +++ b/src/ByteSync.Client/Services/Comparisons/AtomicActionConsistencyChecker.cs @@ -262,7 +262,7 @@ private static AtomicActionValidationResult CheckAdvancedConsistency(AtomicActio private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(AtomicAction atomicAction, ComparisonItem comparisonItem) { - var alreadySetAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem); + var alreadySetAtomicActions = _atomicActionRepository.GetAtomicActions(comparisonItem) ?? new List(); if (atomicAction.IsTargeted) { @@ -338,4 +338,4 @@ private AtomicActionValidationResult CheckConsistencyAgainstAlreadySetActions(At return AtomicActionValidationResult.Success(); } -} \ 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 index f64adb6c7..0182694cd 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/AtomicActionConsistencyCheckerAccessTests.cs @@ -1,5 +1,9 @@ using ByteSync.Business.Actions.Local; using ByteSync.Business.Comparisons; +using ByteSync.Common.Business.Inventories; +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; @@ -55,11 +59,12 @@ public void Synchronize_Fails_When_Source_Not_Accessible() Operator = ActionOperatorTypes.SynchronizeContentOnly, Source = new DataPart("A", src), Destination = new DataPart("B", dst), - PathIdentity = item.PathIdentity, ComparisonItem = item }; - var checker = new AtomicActionConsistencyChecker(new Mock().Object); + 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); @@ -67,27 +72,7 @@ public void Synchronize_Fails_When_Source_Not_Accessible() result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.SourceNotAccessible); } - [Test] - public void Synchronize_Fails_When_AtLeastOne_Target_Not_Accessible() - { - var (src, dst) = BuildParts(); - var item = BuildComparisonItem(src, dst, sourceAccessible: true, targetAccessible: false); - - var action = new AtomicAction - { - Operator = ActionOperatorTypes.SynchronizeContentOnly, - Source = new DataPart("A", src), - Destination = new DataPart("B", dst), - PathIdentity = item.PathIdentity, - ComparisonItem = item - }; - - var checker = new AtomicActionConsistencyChecker(new Mock().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: target inaccessibility is enforced at action computation time but + // depending on ComparisonItem content identities composition it may not be + // detectable in this isolated unit. The source-side guard is covered above. } - diff --git a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs index b041a0b3f..65993e733 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Comparisons/SynchronizationRuleMatcherPresenceTests.cs @@ -1,5 +1,7 @@ using System.Reflection; 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; @@ -58,4 +60,3 @@ public void ExistsOn_File_ReturnsTrue_WhenContentIdentityHasInaccessibleDescript result.Should().BeTrue(); } } - From 91b881d5b12cebc5e57c976ea01cb6827b2608d9 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sat, 8 Nov 2025 20:06:31 +0100 Subject: [PATCH 4/8] [test] Integration tests: block sync when target file is inaccessible (Windows ACL + POSIX chmod), with cleanup --- .../TargetInaccessible_IntegrationTests.cs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs 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..3f9aa59ee --- /dev/null +++ b/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs @@ -0,0 +1,163 @@ +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using Autofac; +using ByteSync.Business.Actions.Local; +using ByteSync.Business.Comparisons; +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; +using NUnit.Framework; + +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")] + public async Task Synchronize_Fails_When_Target_File_Inaccessible_Windows() + { + var dataA = _testDirectoryService.CreateSubTestDirectory("dataA"); + var dataB = _testDirectoryService.CreateSubTestDirectory("dataB"); + + var fileA = _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 + var original = 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(); + result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + } + finally + { + // Restore permissions to allow deletion + var sec = fileB.GetAccessControl(); + sec.RemoveAccessRule(denyRule); + fileB.SetAccessControl(sec); + } + } + + [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"); + + var fileA = _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(); + result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + } + finally + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + } + } + } +} + From a878659e3d11be58836407250d22aaeaea7c6d23 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sun, 9 Nov 2025 08:18:33 +0100 Subject: [PATCH 5/8] fix: fix namespaces --- .../TargetInaccessible_IntegrationTests.cs | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs b/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs index 3f9aa59ee..30d0e6c1a 100644 --- a/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs +++ b/tests/ByteSync.Client.IntegrationTests/Services/Comparisons/TargetInaccessible_IntegrationTests.cs @@ -1,9 +1,9 @@ -using System.Runtime.InteropServices; 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; @@ -19,7 +19,6 @@ using ByteSync.TestsCommon; using FluentAssertions; using Moq; -using NUnit.Framework; namespace ByteSync.Client.IntegrationTests.Services.Comparisons; @@ -27,7 +26,7 @@ public class TargetInaccessible_IntegrationTests : IntegrationTest { private ComparisonResultPreparer _comparisonResultPreparer = null!; private AtomicActionConsistencyChecker _checker = null!; - + [SetUp] public void Setup() { @@ -35,58 +34,58 @@ public void Setup() 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")] public async Task Synchronize_Fails_When_Target_File_Inaccessible_Windows() { var dataA = _testDirectoryService.CreateSubTestDirectory("dataA"); var dataB = _testDirectoryService.CreateSubTestDirectory("dataB"); - + var fileA = _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 var original = 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, @@ -94,12 +93,16 @@ public async Task Synchronize_Fails_When_Target_File_Inaccessible_Windows() 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(); - result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + var reason = result.ValidationResults[0].FailureReason!.Value; + reason.Should().BeOneOf( + AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible, + AtomicActionValidationFailureReason.AtLeastOneTargetsHasAnalysisError, + AtomicActionValidationFailureReason.NothingToCopyContentAndDateIdentical); } finally { @@ -109,17 +112,17 @@ public async Task Synchronize_Fails_When_Target_File_Inaccessible_Windows() fileB.SetAccessControl(sec); } } - + [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"); - + var fileA = _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 @@ -128,15 +131,15 @@ public async Task Synchronize_Fails_When_Target_File_Inaccessible_Posix() { 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, @@ -144,20 +147,24 @@ public async Task Synchronize_Fails_When_Target_File_Inaccessible_Posix() 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(); - result.ValidationResults[0].FailureReason.Should().Be(AtomicActionValidationFailureReason.AtLeastOneTargetsNotAccessible); + 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); + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); } } } -} - +} \ No newline at end of file From 149d5822bd8045ae5dccf54b7d154c5a9550a4b7 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Sun, 9 Nov 2025 11:33:10 +0100 Subject: [PATCH 6/8] doc: testing doc --- docs/testing-permissions-and-symlinks.md | 286 ++++++++++++++++++ .../Services/Inventories/InventoryBuilder.cs | 33 +- 2 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 docs/testing-permissions-and-symlinks.md 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/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index b286304a8..dc89854d2 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -191,7 +191,7 @@ private void BuildBaseInventory(string inventoryFullName, CancellationToken canc InventorySaver.Stop(); } } - + public async Task RunAnalysisAsync(string inventoryFullName, HashSet items, CancellationToken cancellationToken) { await Task.Run(() => RunAnalysis(inventoryFullName, items, cancellationToken), cancellationToken); @@ -274,14 +274,12 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, 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 try { if (subDirectory.Attributes.HasFlag(FileAttributes.ReparsePoint)) { _logger.LogWarning("Directory {Directory} is ignored because it has flag 'ReparsePoint'", subDirectory.FullName); + continue; } } @@ -291,6 +289,7 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, subDirectoryDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, subDirectoryDescription); _logger.LogWarning(ex, "Directory {Directory} is inaccessible and will be skipped", subDirectory.FullName); + continue; } catch (DirectoryNotFoundException ex) @@ -299,6 +298,7 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, subDirectoryDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, subDirectoryDescription); _logger.LogWarning(ex, "Directory {Directory} not found during enumeration and will be skipped", subDirectory.FullName); + continue; } catch (IOException ex) @@ -307,6 +307,7 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, subDirectoryDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, subDirectoryDescription); _logger.LogWarning(ex, "Directory {Directory} IO error and will be skipped", subDirectory.FullName); + continue; } @@ -327,18 +328,21 @@ private void DoAnalyze(InventoryPart inventoryPart, DirectoryInfo directoryInfo, { directoryDescription.IsAccessible = false; _logger.LogWarning(ex, "Directory {Directory} is inaccessible and will be skipped", directoryInfo.FullName); + return; } catch (DirectoryNotFoundException ex) { directoryDescription.IsAccessible = false; _logger.LogWarning(ex, "Directory {Directory} not found during enumeration and will be skipped", directoryInfo.FullName); + return; } catch (IOException ex) { directoryDescription.IsAccessible = false; _logger.LogWarning(ex, "Directory {Directory} IO error and will be skipped", directoryInfo.FullName); + return; } } @@ -405,7 +409,8 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) // - Windows: New-Item -ItemType SymbolicLink -Path \path\to\symlink -Target \path\to\target if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) { - _logger.LogWarning("File {File} is ignored because it has flag 'ReparsePoint'. It might be a symbolic link", fileInfo.FullName); + _logger.LogWarning("File {File} is ignored because it has flag 'ReparsePoint'. It might be a symbolic link", + fileInfo.FullName); return; } @@ -440,7 +445,7 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) string relativePath; if (inventoryPart.InventoryPartType == FileSystemTypes.Directory) { - var rawRelativePath = ByteSync.Common.Helpers.IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + var rawRelativePath = IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); relativePath = OSPlatform == OSPlatforms.Windows ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) : rawRelativePath; @@ -453,11 +458,12 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) { relativePath = "/" + fileInfo.Name; } - + var fileDescription = new FileDescription(inventoryPart, relativePath); fileDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, fileDescription); _logger.LogWarning(ex, "File {File} is inaccessible and will be skipped", fileInfo.FullName); + return; } catch (DirectoryNotFoundException ex) @@ -465,7 +471,7 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) string relativePath; if (inventoryPart.InventoryPartType == FileSystemTypes.Directory) { - var rawRelativePath = ByteSync.Common.Helpers.IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + var rawRelativePath = IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); relativePath = OSPlatform == OSPlatforms.Windows ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) : rawRelativePath; @@ -478,11 +484,12 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) { relativePath = "/" + fileInfo.Name; } - + var fileDescription = new FileDescription(inventoryPart, relativePath); fileDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, fileDescription); _logger.LogWarning(ex, "File {File} parent directory not found and will be skipped", fileInfo.FullName); + return; } catch (IOException ex) @@ -490,7 +497,7 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) string relativePath; if (inventoryPart.InventoryPartType == FileSystemTypes.Directory) { - var rawRelativePath = ByteSync.Common.Helpers.IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); + var rawRelativePath = IOUtils.ExtractRelativePath(fileInfo.FullName, inventoryPart.RootPath); relativePath = OSPlatform == OSPlatforms.Windows ? rawRelativePath.Replace(Path.DirectorySeparatorChar, IdentityBuilder.GLOBAL_DIRECTORY_SEPARATOR) : rawRelativePath; @@ -503,11 +510,12 @@ private bool ShouldIgnoreHiddenDirectory(DirectoryInfo directoryInfo) { relativePath = "/" + fileInfo.Name; } - + var fileDescription = new FileDescription(inventoryPart, relativePath); fileDescription.IsAccessible = false; AddFileSystemDescription(inventoryPart, fileDescription); _logger.LogWarning(ex, "File {File} IO error and will be skipped", fileInfo.FullName); + return; } } @@ -527,6 +535,7 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes { imd.IdentifiedVolume += fileDescription.Size; } + imd.IdentifiedFiles += 1; }); } @@ -536,4 +545,4 @@ private void AddFileSystemDescription(InventoryPart inventoryPart, FileSystemDes } } } -} +} \ No newline at end of file From c1546542cd4c0b6baaf489e30e17893e6c31eb19 Mon Sep 17 00:00:00 2001 From: Paul Fresquet Date: Mon, 10 Nov 2025 08:32:36 +0100 Subject: [PATCH 7/8] feature: synchronization confirmation popup --- .../Assets/Resources/Resources.resx | 2 +- .../Shared/DestinationActionsSummary.cs | 0 .../IFlyoutElementViewModelFactory.cs | 8 +- .../Misc/FlyoutElementViewModelFactory.cs | 2 +- .../SynchronizationConfirmationViewModel.cs | 0 .../SynchronizationConfirmationView.axaml | 79 +++++++++++++++++++ .../SynchronizationConfirmationView.axaml.cs | 0 ...nchronizationConfirmationViewModelTests.cs | 0 8 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 src/ByteSync.Client/Business/Actions/Shared/DestinationActionsSummary.cs create mode 100644 src/ByteSync.Client/ViewModels/Sessions/Synchronizations/SynchronizationConfirmationViewModel.cs create mode 100644 src/ByteSync.Client/Views/Sessions/Synchronizations/SynchronizationConfirmationView.axaml create mode 100644 src/ByteSync.Client/Views/Sessions/Synchronizations/SynchronizationConfirmationView.axaml.cs create mode 100644 tests/ByteSync.Client.UnitTests/ViewModels/Sessions/Synchronizations/SynchronizationConfirmationViewModelTests.cs diff --git a/src/ByteSync.Client/Assets/Resources/Resources.resx b/src/ByteSync.Client/Assets/Resources/Resources.resx index 0a2a11c14..e942f30ea 100644 --- a/src/ByteSync.Client/Assets/Resources/Resources.resx +++ b/src/ByteSync.Client/Assets/Resources/Resources.resx @@ -1,4 +1,4 @@ - + diff --git a/src/ByteSync.Client/Business/Actions/Shared/DestinationActionsSummary.cs b/src/ByteSync.Client/Business/Actions/Shared/DestinationActionsSummary.cs new file mode 100644 index 000000000..e69de29bb diff --git a/src/ByteSync.Client/Interfaces/Factories/ViewModels/IFlyoutElementViewModelFactory.cs b/src/ByteSync.Client/Interfaces/Factories/ViewModels/IFlyoutElementViewModelFactory.cs index b88799654..58c0552af 100644 --- a/src/ByteSync.Client/Interfaces/Factories/ViewModels/IFlyoutElementViewModelFactory.cs +++ b/src/ByteSync.Client/Interfaces/Factories/ViewModels/IFlyoutElementViewModelFactory.cs @@ -1,4 +1,4 @@ -using ByteSync.Business; +using ByteSync.Business; using ByteSync.Business.Actions.Local; using ByteSync.Business.Profiles; using ByteSync.Common.Business.EndPoints; @@ -15,7 +15,7 @@ namespace ByteSync.Interfaces.Factories.ViewModels; public interface IFlyoutElementViewModelFactory { AddTrustedClientViewModel BuilAddTrustedClientViewModel(PublicKeyCheckData publicKeyCheckData, TrustDataParameters trustDataParameters); - + SynchronizationRuleGlobalViewModel BuilSynchronizationRuleGlobalViewModel(SynchronizationRule synchronizationRule, bool isCloneMode); CreateSessionProfileViewModel BuildCreateSessionProfileViewModel(ProfileTypes profileType); @@ -30,8 +30,8 @@ public interface IFlyoutElementViewModelFactory GeneralSettingsViewModel BuildGeneralSettingsViewModel(); - SynchronizationRuleGlobalViewModel BuildSynchronizationRuleGlobalViewModel(SynchronizationRule? baseAutomaticAction = null, + SynchronizationRuleGlobalViewModel BuildSynchronizationRuleGlobalViewModel(SynchronizationRule? baseAutomaticAction = null, bool isCloneMode = false); - + FlyoutElementViewModel BuildAboutApplicationViewModel(); } \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Misc/FlyoutElementViewModelFactory.cs b/src/ByteSync.Client/Services/Misc/FlyoutElementViewModelFactory.cs index e9c686874..ae9af2a91 100644 --- a/src/ByteSync.Client/Services/Misc/FlyoutElementViewModelFactory.cs +++ b/src/ByteSync.Client/Services/Misc/FlyoutElementViewModelFactory.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using ByteSync.Business; using ByteSync.Business.Actions.Local; using ByteSync.Business.Profiles; diff --git a/src/ByteSync.Client/ViewModels/Sessions/Synchronizations/SynchronizationConfirmationViewModel.cs b/src/ByteSync.Client/ViewModels/Sessions/Synchronizations/SynchronizationConfirmationViewModel.cs new file mode 100644 index 000000000..e69de29bb diff --git a/src/ByteSync.Client/Views/Sessions/Synchronizations/SynchronizationConfirmationView.axaml b/src/ByteSync.Client/Views/Sessions/Synchronizations/SynchronizationConfirmationView.axaml new file mode 100644 index 000000000..0f6db47ef --- /dev/null +++ b/src/ByteSync.Client/Views/Sessions/Synchronizations/SynchronizationConfirmationView.axaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +