diff --git a/.claude/settings.json b/.claude/settings.json index 125ca5d..6770909 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,8 @@ "Bash(docker compose:*)", "Bash(docker exec:*)", "Bash(gh run view:*)", - "WebFetch(domain:api.codecov.io)" + "WebFetch(domain:api.codecov.io)", + "WebSearch" ], "deny": [] } diff --git a/CLAUDE.md b/CLAUDE.md index e178893..ecc5222 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,17 +88,17 @@ SharpSync is a **pure .NET file synchronization library** with no native depende 1. **Core Interfaces** (`src/SharpSync/Core/`) - `ISyncEngine` - Main synchronization orchestrator (`SynchronizeAsync`, `PreviewSyncAsync`, `GetSyncPlanAsync`, `GetStatsAsync`, `ResetSyncStateAsync`, plus selective/incremental sync and lifecycle methods) - - `ISyncStorage` - Storage backend abstraction (local, WebDAV, cloud) with `ProgressChanged` event + - `ISyncStorage` - Storage backend abstraction (local, WebDAV, cloud) with `ProgressChanged` event and default methods for `SetLastModifiedAsync`/`SetPermissionsAsync` - `ISyncDatabase` - Sync state persistence - `IConflictResolver` - Pluggable conflict resolution strategies - `ISyncFilter` - File filtering for selective sync - - Domain models: `SyncItem`, `SyncOptions`, `SyncProgress`, `SyncResult` + - Domain models: `SyncItem` (with `IsSymlink` support), `SyncOptions`, `SyncProgress`, `SyncResult` 2. **Storage Implementations** (`src/SharpSync/Storage/`) - - `LocalFileStorage` - Local filesystem operations (fully implemented and tested) + - `LocalFileStorage` - Local filesystem operations with symlink detection, timestamp/permission preservation (fully implemented and tested) - `WebDavStorage` - WebDAV with OAuth2, chunking, and platform-specific optimizations (fully implemented and tested) - - `SftpStorage` - SFTP with password and key-based authentication (fully implemented and tested) - - `FtpStorage` - FTP/FTPS with secure connections support (fully implemented and tested) + - `SftpStorage` - SFTP with password and key-based authentication, symlink detection, timestamp/permission preservation (fully implemented and tested) + - `FtpStorage` - FTP/FTPS with secure connections support and timestamp preservation (fully implemented and tested) - `S3Storage` - Amazon S3 and S3-compatible storage (MinIO, LocalStack) with multipart uploads (fully implemented and tested) Additional public types in `src/SharpSync/Storage/`: @@ -119,9 +119,10 @@ SharpSync is a **pure .NET file synchronization library** with no native depende 5. **Synchronization Engine** (`src/SharpSync/Sync/`) - `SyncEngine` - Production-ready sync implementation with: - - Incremental sync with change detection + - Incremental sync with change detection (timestamp, checksum-only, or size-only modes) - Parallel processing for large file sets - Three-phase optimization (directories/small files, large files, deletes/conflicts) + - All `SyncOptions` properties fully wired: `TimeoutSeconds`, `ChecksumOnly`, `SizeOnly`, `UpdateExisting`, `ConflictResolution` override, `ExcludePatterns`, `Verbose`, `FollowSymlinks`, `PreserveTimestamps`, `PreservePermissions` - `SyncFilter` - Pattern-based file filtering Internal sync pipeline types (in `Oire.SharpSync.Sync` namespace): @@ -151,7 +152,12 @@ SharpSync is a **pure .NET file synchronization library** with no native depende - **Parallel Processing**: Configurable parallelism with intelligent prioritization - **Bandwidth Throttling**: Configurable transfer rate limits via `SyncOptions.MaxBytesPerSecond` - **Virtual File Support**: Callback hook for Windows Cloud Files API placeholder integration -- **Structured Logging**: High-performance logging via `Microsoft.Extensions.Logging` +- **Timestamp Preservation**: Optionally preserves file modification times across sync (`PreserveTimestamps`) +- **Permission Preservation**: Optionally preserves Unix file permissions across sync (`PreservePermissions`) +- **Symlink Awareness**: Detects symlinks and optionally follows or skips them (`FollowSymlinks`) +- **Flexible Change Detection**: Checksum-only or size-only modes for change detection +- **Per-Sync Exclusion**: Runtime exclude patterns via `SyncOptions.ExcludePatterns` +- **Structured Logging**: High-performance logging via `Microsoft.Extensions.Logging` with verbose mode ### Dependencies @@ -391,6 +397,10 @@ var deleted = await engine.ClearOperationHistoryAsync(DateTime.UtcNow.AddDays(-3 | Activity history | `GetRecentOperationsAsync()` - query completed operations for activity feed | | History cleanup | `ClearOperationHistoryAsync()` - purge old operation records | | Per-file progress | `FileProgressChanged` event on `ISyncEngine` - byte-level progress for individual file transfers | +| SyncOptions wiring | All `SyncOptions` properties are now functional: `TimeoutSeconds`, `ChecksumOnly`, `SizeOnly`, `UpdateExisting`, `ConflictResolution` override, `ExcludePatterns`, `Verbose`, `FollowSymlinks`, `PreserveTimestamps`, `PreservePermissions` | +| Timestamp preservation | `ISyncStorage.SetLastModifiedAsync` default interface method, implemented in Local/SFTP/FTP storage | +| Permission preservation | `ISyncStorage.SetPermissionsAsync` default interface method, implemented in Local/SFTP storage | +| Symlink awareness | `SyncItem.IsSymlink` property, detected in Local/SFTP storage, `FollowSymlinks` option in SyncEngine | ### Required SharpSync API Additions (v1.0) @@ -520,3 +530,6 @@ All critical items have been resolved. - ✅ Examples directory with working samples - ✅ Code coverage reporting (Coverlet + Codecov with badge in README) - ✅ Console OAuth2 provider example (`examples/ConsoleOAuth2Example.cs`) +- ✅ All `SyncOptions` properties wired and functional (TimeoutSeconds, ChecksumOnly, SizeOnly, UpdateExisting, ConflictResolution override, ExcludePatterns, Verbose, FollowSymlinks, PreserveTimestamps, PreservePermissions) +- ✅ `ISyncStorage.SetLastModifiedAsync` / `SetPermissionsAsync` default interface methods +- ✅ Symlink detection (`SyncItem.IsSymlink`) in Local and SFTP storage diff --git a/README.md b/README.md index 9f740ce..f48f2fc 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,7 @@ var storage = new FtpStorage( host: "ftp.example.com", username: "user", password: "password", - useSsl: true, - sslMode: FtpSslMode.Explicit + useFtps: true ); // Implicit FTPS @@ -203,8 +202,8 @@ var storage = new FtpStorage( port: 990, username: "user", password: "password", - useSsl: true, - sslMode: FtpSslMode.Implicit + useFtps: true, + useImplicitFtps: true ); ``` diff --git a/TESTING.md b/TESTING.md index 8c2e811..09e4955 100644 --- a/TESTING.md +++ b/TESTING.md @@ -91,13 +91,13 @@ export SFTP_TEST_ROOT=/home/testuser/upload dotnet test --verbosity normal ``` -### WebDAV Integration Tests (Future) +### WebDAV Integration Tests -WebDAV integration tests are planned for future releases. They will follow a similar pattern: +WebDAV integration tests verify connectivity, file operations, and server capability detection against a real WebDAV server. The tests are located in `tests/SharpSync.Tests/Storage/WebDavStorageTests.cs`. ```bash -# Using Docker Compose -docker-compose -f docker-compose.test.yml up -d webdav +# Using Docker Compose (starts the WebDAV server along with other test services) +docker-compose -f docker-compose.test.yml up -d export WEBDAV_TEST_URL=http://localhost:8080/webdav export WEBDAV_TEST_USER=testuser @@ -106,6 +106,8 @@ export WEBDAV_TEST_PASS=testpass dotnet test --verbosity normal ``` +Like other integration tests, WebDAV tests skip automatically when the required environment variables are not set. + ## Continuous Integration The GitHub Actions workflow automatically runs all tests, including SFTP integration tests, using a Docker-based SFTP server. See `.github/workflows/dotnet.yml` for the configuration. @@ -220,14 +222,7 @@ public async Task MyNewIntegrationTest() { ## Performance Testing -For performance benchmarks, use BenchmarkDotNet: - -```bash -# Run in Release mode -dotnet run -c Release --project benchmarks/SharpSync.Benchmarks -``` - -(Note: Benchmarks project not yet implemented) +Performance benchmarks using BenchmarkDotNet are planned for a future release. ## Additional Resources diff --git a/examples/BasicSyncExample.cs b/examples/BasicSyncExample.cs index 3b50022..5239d1c 100644 --- a/examples/BasicSyncExample.cs +++ b/examples/BasicSyncExample.cs @@ -44,7 +44,7 @@ public static async Task BasicSyncAsync() { filter.AddExcludePattern("node_modules/**"); // 4. Create conflict resolver - var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseNewer); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseRemote); // 5. Create sync engine using var syncEngine = new SyncEngine( @@ -214,6 +214,75 @@ public static async Task ThrottledSyncAsync(ISyncEngine syncEngine) { Console.WriteLine($"Throttled sync completed: {result.FilesSynchronized} files"); } + /// + /// Configure sync options for fine-grained control over synchronization behavior. + /// + public static async Task SyncWithOptionsAsync(ISyncEngine syncEngine) { + // Checksum-only mode: detect changes by file hash instead of timestamps. + // Useful when timestamps are unreliable (e.g., after restoring from backup). + var checksumOptions = new SyncOptions { ChecksumOnly = true }; + await syncEngine.SynchronizeAsync(checksumOptions); + + // Size-only mode: detect changes by file size only (fastest, least accurate). + // Good for quick checks when only large content changes matter. + var sizeOptions = new SyncOptions { SizeOnly = true }; + await syncEngine.SynchronizeAsync(sizeOptions); + + // Preserve timestamps and permissions across sync. + // Timestamps are set on the target after each file transfer. + // Permissions (Unix only) are preserved for Local and SFTP storage. + var preserveOptions = new SyncOptions { + PreserveTimestamps = true, + PreservePermissions = true + }; + await syncEngine.SynchronizeAsync(preserveOptions); + + // Skip symlink directories during sync. + // When false (default), symlink directories are not followed. + var symlinkOptions = new SyncOptions { FollowSymlinks = true }; + await syncEngine.SynchronizeAsync(symlinkOptions); + + // Per-sync exclude patterns (applied in addition to the engine-level SyncFilter). + // Useful for one-off syncs that need extra filtering without modifying the filter. + var excludeOptions = new SyncOptions { + ExcludePatterns = new List { "*.bak", "thumbs.db", "*.tmp" } + }; + await syncEngine.SynchronizeAsync(excludeOptions); + + // Timeout: cancel sync if it exceeds the given number of seconds. + var timeoutOptions = new SyncOptions { TimeoutSeconds = 300 }; + await syncEngine.SynchronizeAsync(timeoutOptions); + + // UpdateExisting=false: only sync new files, skip modifications to existing files. + var newOnlyOptions = new SyncOptions { UpdateExisting = false }; + await syncEngine.SynchronizeAsync(newOnlyOptions); + + // Override conflict resolution per-sync via options. + // This takes priority over the IConflictResolver passed to the engine constructor. + // Set to ConflictResolution.Ask to delegate to the resolver instead. + var conflictOptions = new SyncOptions { + ConflictResolution = ConflictResolution.UseLocal + }; + await syncEngine.SynchronizeAsync(conflictOptions); + + // Verbose logging: emits detailed Debug-level log messages for change detection, + // action processing, and phase completion. Requires an ILogger to be + // passed to the SyncEngine constructor. + var verboseOptions = new SyncOptions { Verbose = true }; + await syncEngine.SynchronizeAsync(verboseOptions); + + // Options can be combined freely. + var combinedOptions = new SyncOptions { + ChecksumOnly = true, + PreserveTimestamps = true, + ExcludePatterns = new List { "*.log" }, + TimeoutSeconds = 600, + Verbose = true + }; + var result = await syncEngine.SynchronizeAsync(combinedOptions); + Console.WriteLine($"Combined sync completed: {result.FilesSynchronized} files"); + } + /// /// Smart conflict resolution with UI callback. /// diff --git a/examples/README.md b/examples/README.md index 4188c27..72ea3ad 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ A comprehensive example showing: - **Selective sync** - Syncing specific files or folders on demand - **Pause/Resume** - Controlling long-running sync operations - **Bandwidth throttling** - Limiting transfer speeds +- **Sync options** - Configuring `ChecksumOnly`, `SizeOnly`, `PreserveTimestamps`, `PreservePermissions`, `FollowSymlinks`, `ExcludePatterns`, `TimeoutSeconds`, `UpdateExisting`, `ConflictResolution` override, and `Verbose` logging - **Smart conflict resolution** - Handling conflicts with UI prompts ## ConsoleOAuth2Example.cs @@ -72,7 +73,7 @@ await database.InitializeAsync(); // Create sync engine var filter = new SyncFilter(); -var resolver = new DefaultConflictResolver(ConflictResolution.UseNewer); +var resolver = new DefaultConflictResolver(ConflictResolution.UseRemote); using var engine = new SyncEngine(localStorage, remoteStorage, database, filter, resolver); // Run sync diff --git a/src/SharpSync/Core/ISyncStorage.cs b/src/SharpSync/Core/ISyncStorage.cs index a455f01..1c9b698 100644 --- a/src/SharpSync/Core/ISyncStorage.cs +++ b/src/SharpSync/Core/ISyncStorage.cs @@ -79,4 +79,30 @@ public interface ISyncStorage { /// Tests connection to the storage /// Task TestConnectionAsync(CancellationToken cancellationToken = default); + + /// + /// Sets the last modified time for a file or directory. + /// + /// + /// Not all storage backends support setting modification times. The default implementation + /// is a no-op. Implementations that support this (e.g., local filesystem, SFTP, FTP) + /// should override this method. + /// + /// The relative path to the item + /// The last modified time to set (UTC) + /// Cancellation token to cancel the operation + Task SetLastModifiedAsync(string path, DateTime lastModified, CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + /// Sets file permissions for a file or directory. + /// + /// + /// Not all storage backends or platforms support setting file permissions. + /// The default implementation is a no-op. Implementations that support this + /// (e.g., local filesystem on Unix, SFTP) should override this method. + /// + /// The relative path to the item + /// The permissions string (e.g., "rwxr-xr-x" or "755") + /// Cancellation token to cancel the operation + Task SetPermissionsAsync(string path, string permissions, CancellationToken cancellationToken = default) => Task.CompletedTask; } diff --git a/src/SharpSync/Core/SyncItem.cs b/src/SharpSync/Core/SyncItem.cs index 7fdfd62..87d9c7d 100644 --- a/src/SharpSync/Core/SyncItem.cs +++ b/src/SharpSync/Core/SyncItem.cs @@ -34,6 +34,11 @@ public class SyncItem { /// public string? ETag { get; set; } + /// + /// Gets or sets whether this item is a symbolic link + /// + public bool IsSymlink { get; set; } + /// /// Gets or sets additional metadata /// diff --git a/src/SharpSync/Logging/LogMessages.cs b/src/SharpSync/Logging/LogMessages.cs index b273f7d..bac985c 100644 --- a/src/SharpSync/Logging/LogMessages.cs +++ b/src/SharpSync/Logging/LogMessages.cs @@ -77,4 +77,46 @@ internal static partial class LogMessages { Level = LogLevel.Warning, Message = "Failed to log operation for {Path}")] public static partial void OperationLoggingError(this ILogger logger, Exception ex, string path); + + [LoggerMessage( + EventId = 13, + Level = LogLevel.Debug, + Message = "Detecting changes (options: DryRun={DryRun}, ChecksumOnly={ChecksumOnly}, SizeOnly={SizeOnly})")] + public static partial void DetectChangesStart(this ILogger logger, bool dryRun, bool checksumOnly, bool sizeOnly); + + [LoggerMessage( + EventId = 14, + Level = LogLevel.Debug, + Message = "Change detection complete: {Additions} additions, {Modifications} modifications, {Deletions} deletions")] + public static partial void DetectChangesComplete(this ILogger logger, int additions, int modifications, int deletions); + + [LoggerMessage( + EventId = 15, + Level = LogLevel.Debug, + Message = "HasChanged {Path}: isLocal={IsLocal}, result={Changed}")] + public static partial void HasChangedResult(this ILogger logger, string path, bool isLocal, bool changed); + + [LoggerMessage( + EventId = 16, + Level = LogLevel.Debug, + Message = "Processing action: {ActionType} {Path}")] + public static partial void ProcessingAction(this ILogger logger, Core.SyncActionType actionType, string path); + + [LoggerMessage( + EventId = 17, + Level = LogLevel.Debug, + Message = "Phase {Phase} complete: processed {Count} actions")] + public static partial void PhaseComplete(this ILogger logger, int phase, int count); + + [LoggerMessage( + EventId = 18, + Level = LogLevel.Warning, + Message = "Failed to preserve timestamps for {Path}")] + public static partial void TimestampPreservationError(this ILogger logger, Exception ex, string path); + + [LoggerMessage( + EventId = 19, + Level = LogLevel.Warning, + Message = "Failed to preserve permissions for {Path}")] + public static partial void PermissionPreservationError(this ILogger logger, Exception ex, string path); } diff --git a/src/SharpSync/Storage/FtpStorage.cs b/src/SharpSync/Storage/FtpStorage.cs index 3c4b1d3..1b59054 100644 --- a/src/SharpSync/Storage/FtpStorage.cs +++ b/src/SharpSync/Storage/FtpStorage.cs @@ -473,6 +473,19 @@ public async Task ComputeHashAsync(string path, CancellationToken cancel return Convert.ToBase64String(hashBytes); } + /// + /// Sets the last modified time for a file on the FTP server + /// + public async Task SetLastModifiedAsync(string path, DateTime lastModified, CancellationToken cancellationToken = default) { + await EnsureConnectedAsync(cancellationToken); + var fullPath = GetFullPath(path); + + await ExecuteWithRetry(async () => { + await _client!.SetModifiedTime(fullPath, lastModified, cancellationToken); + return true; + }, cancellationToken); + } + #region Helper Methods /// diff --git a/src/SharpSync/Storage/LocalFileStorage.cs b/src/SharpSync/Storage/LocalFileStorage.cs index efccf28..7a108ac 100644 --- a/src/SharpSync/Storage/LocalFileStorage.cs +++ b/src/SharpSync/Storage/LocalFileStorage.cs @@ -74,23 +74,32 @@ public async Task> ListItemsAsync(string path, Cancellatio items.Add(new SyncItem { Path = GetRelativePath(dir), IsDirectory = true, + IsSymlink = dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint), LastModified = dirInfo.LastWriteTimeUtc, Size = 0 }); } // Get files + var isUnix = OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); foreach (var file in Directory.EnumerateFiles(fullPath)) { cancellationToken.ThrowIfCancellationRequested(); var fileInfo = new FileInfo(file); - items.Add(new SyncItem { + var item = new SyncItem { Path = GetRelativePath(file), IsDirectory = false, + IsSymlink = fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint), LastModified = fileInfo.LastWriteTimeUtc, Size = fileInfo.Length, MimeType = GetMimeType(file) - }); + }; + + if (isUnix) { + item.Permissions = Convert.ToString((int)fileInfo.UnixFileMode, 8); + } + + items.Add(item); } return await Task.FromResult(items); @@ -110,6 +119,7 @@ public async Task> ListItemsAsync(string path, Cancellatio return await Task.FromResult(new SyncItem { Path = path, IsDirectory = true, + IsSymlink = dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint), LastModified = dirInfo.LastWriteTimeUtc, Size = 0 }); @@ -120,6 +130,7 @@ public async Task> ListItemsAsync(string path, Cancellatio return await Task.FromResult(new SyncItem { Path = path, IsDirectory = false, + IsSymlink = fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint), LastModified = fileInfo.LastWriteTimeUtc, Size = fileInfo.Length, MimeType = GetMimeType(fullPath) @@ -277,6 +288,90 @@ public async Task TestConnectionAsync(CancellationToken cancellationToken return await Task.FromResult(Directory.Exists(RootPath)); } + /// + /// Sets the last modified time for a file or directory on the local filesystem + /// + public Task SetLastModifiedAsync(string path, DateTime lastModified, CancellationToken cancellationToken = default) { + var fullPath = GetFullPath(path); + + if (File.Exists(fullPath)) { + File.SetLastWriteTimeUtc(fullPath, lastModified); + } else if (Directory.Exists(fullPath)) { + Directory.SetLastWriteTimeUtc(fullPath, lastModified); + } + + return Task.CompletedTask; + } + + /// + /// Sets file permissions on the local filesystem (Unix/macOS only) + /// + public Task SetPermissionsAsync(string path, string permissions, CancellationToken cancellationToken = default) { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) { + return Task.CompletedTask; + } + + var fullPath = GetFullPath(path); + if (!File.Exists(fullPath) && !Directory.Exists(fullPath)) { + return Task.CompletedTask; + } + + // Parse permissions string - support both "rwxr-xr-x" and numeric "755" formats + if (TryParseUnixFileMode(permissions, out var mode)) { + File.SetUnixFileMode(fullPath, mode); + } + + return Task.CompletedTask; + } + + /// + /// Parses a permission string into a UnixFileMode value. + /// Supports numeric format (e.g., "755") and symbolic format (e.g., "-rwxr-xr-x"). + /// + private static bool TryParseUnixFileMode(string permissions, out UnixFileMode mode) { + mode = UnixFileMode.None; + if (string.IsNullOrWhiteSpace(permissions)) { + return false; + } + + // Try numeric format (e.g., "755") - validate it's all digits, then parse as octal + if (permissions.Length >= 3 && permissions.Length <= 4 && permissions.All(char.IsDigit)) { + mode = (UnixFileMode)Convert.ToInt32(permissions, 8); + return true; + } + + // Try symbolic format (e.g., "-rwxr-xr-x" or "drwxr-xr-x") + var perm = permissions; + if (perm.Length == 10) { + perm = perm.Substring(1); // Strip type character (d, l, -) + } + + if (perm.Length != 9) { + return false; + } + + if (perm[0] == 'r') + mode |= UnixFileMode.UserRead; + if (perm[1] == 'w') + mode |= UnixFileMode.UserWrite; + if (perm[2] == 'x') + mode |= UnixFileMode.UserExecute; + if (perm[3] == 'r') + mode |= UnixFileMode.GroupRead; + if (perm[4] == 'w') + mode |= UnixFileMode.GroupWrite; + if (perm[5] == 'x') + mode |= UnixFileMode.GroupExecute; + if (perm[6] == 'r') + mode |= UnixFileMode.OtherRead; + if (perm[7] == 'w') + mode |= UnixFileMode.OtherWrite; + if (perm[8] == 'x') + mode |= UnixFileMode.OtherExecute; + + return true; + } + private string GetFullPath(string relativePath) { if (string.IsNullOrEmpty(relativePath) || relativePath == "/") { return RootPath; diff --git a/src/SharpSync/Storage/SftpStorage.cs b/src/SharpSync/Storage/SftpStorage.cs index 2c62e08..c16b8d6 100644 --- a/src/SharpSync/Storage/SftpStorage.cs +++ b/src/SharpSync/Storage/SftpStorage.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Security.Cryptography; using Oire.SharpSync.Core; using Renci.SshNet; @@ -329,6 +330,7 @@ public async Task> ListItemsAsync(string path, Cancellatio items.Add(new SyncItem { Path = GetRelativePath(file.FullName), IsDirectory = file.IsDirectory, + IsSymlink = file.Attributes?.IsSymbolicLink ?? false, Size = file.Length, LastModified = file.LastWriteTimeUtc, Permissions = ConvertPermissionsToString(file), @@ -361,6 +363,7 @@ public async Task> ListItemsAsync(string path, Cancellatio return new SyncItem { Path = path, IsDirectory = file.IsDirectory, + IsSymlink = file.Attributes?.IsSymbolicLink ?? false, Size = file.Length, LastModified = file.LastWriteTimeUtc, Permissions = ConvertPermissionsToString(file), @@ -695,6 +698,47 @@ public async Task ComputeHashAsync(string path, CancellationToken cancel return Convert.ToBase64String(hashBytes); } + /// + /// Sets the last modified time for a file on the SFTP server + /// + public async Task SetLastModifiedAsync(string path, DateTime lastModified, CancellationToken cancellationToken = default) { + await EnsureConnectedAsync(cancellationToken); + var fullPath = GetFullPath(path); + + await ExecuteWithRetry(async () => { + if (_client!.Exists(fullPath)) { + var attrs = _client.GetAttributes(fullPath); + attrs.LastWriteTime = lastModified; + await Task.Run(() => _client.SetAttributes(fullPath, attrs), cancellationToken); + } + return true; + }, cancellationToken); + } + + /// + /// Sets file permissions on the SFTP server + /// + public async Task SetPermissionsAsync(string path, string permissions, CancellationToken cancellationToken = default) { + await EnsureConnectedAsync(cancellationToken); + var fullPath = GetFullPath(path); + + await ExecuteWithRetry(async () => { + if (_client!.Exists(fullPath)) { + // Parse numeric permission (e.g., "755") + // SSH.NET's SetPermissions expects the decimal integer 755, not the octal value 0x1ED. + // It extracts digits via decimal division and validates each is 0-7. + if (permissions.Length >= 3 && permissions.Length <= 4 + && short.TryParse(permissions, out var mode) + && permissions.All(c => c >= '0' && c <= '7')) { + var attrs = _client.GetAttributes(fullPath); + attrs.SetPermissions(mode); + await Task.Run(() => _client.SetAttributes(fullPath, attrs), cancellationToken); + } + } + return true; + }, cancellationToken); + } + #region Helper Methods /// diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index f0f4cfc..8965734 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -60,6 +60,9 @@ public class SyncEngine: ISyncEngine { private SyncProgress? _pausedProgress; private TaskCompletionSource? _pauseCompletionSource; + // Per-sync exclude filter built from SyncOptions.ExcludePatterns + private SyncFilter? _perSyncExcludeFilter; + // Pending changes tracking for incremental sync private readonly ConcurrentDictionary _pendingChanges = new(StringComparer.OrdinalIgnoreCase); @@ -184,6 +187,9 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc try { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (options?.TimeoutSeconds > 0) { + linkedCts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); + } _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; _currentOptions = options; @@ -191,6 +197,9 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc var result = new SyncResult(); var sw = Stopwatch.StartNew(); + // Build per-sync exclude filter from options + BuildPerSyncExcludeFilter(options); + // Set state to Running lock (_stateLock) { _state = SyncEngineState.Running; @@ -247,6 +256,7 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc _currentSyncCts = null; _currentMaxBytesPerSecond = null; _currentOptions = null; + _perSyncExcludeFilter = null; _syncSemaphore.Release(); } } @@ -304,7 +314,7 @@ public async Task GetSyncPlanAsync(SyncOptions? options = null, Cancel } // Analyze and prioritize changes - var actionGroups = AnalyzeAndPrioritizeChanges(changes); + var actionGroups = AnalyzeAndPrioritizeChanges(changes, options); // Convert internal actions to public plan actions var planActions = new List(); @@ -412,6 +422,10 @@ private async Task IncorporatePendingChangesAsync(ChangeSet changeSet, Cancellat /// Efficient change detection using database state /// private async Task DetectChangesAsync(SyncOptions? options, CancellationToken cancellationToken) { + if (options?.Verbose is true) { + _logger.DetectChangesStart(options.DryRun, options.ChecksumOnly, options.SizeOnly); + } + var changeSet = new ChangeSet(); // Get all tracked items from database @@ -447,6 +461,10 @@ private async Task DetectChangesAsync(SyncOptions? options, Cancellat await DetectExtraneousFilesAsync(changeSet, cancellationToken); } + if (options?.Verbose is true) { + _logger.DetectChangesComplete(changeSet.Additions.Count, changeSet.Modifications.Count, changeSet.Deletions.Count); + } + return changeSet; } @@ -518,7 +536,12 @@ CancellationToken cancellationToken var tasks = new List(); foreach (var item in items) { - if (!_filter.ShouldSync(item.Path)) { + if (!ShouldSyncItem(item.Path)) { + continue; + } + + // Skip symlink directories when FollowSymlinks is false + if (item.IsDirectory && item.IsSymlink && !(_currentOptions?.FollowSymlinks ?? false)) { continue; } @@ -582,6 +605,17 @@ CancellationToken cancellationToken return true; // Was deleted, now exists } + // SizeOnly: compare only by size + if (_currentOptions?.SizeOnly is true) { + return item.Size != tracked.LocalSize; + } + + // ChecksumOnly: compare only by checksum, skip timestamp checks + if ((_currentOptions?.ChecksumOnly ?? false) && !item.IsDirectory) { + var hash = await storage.ComputeHashAsync(item.Path, cancellationToken); + return hash != tracked.LocalHash; + } + // Use ETag if available (fast) if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.LocalHash) { return false; @@ -610,6 +644,17 @@ CancellationToken cancellationToken return true; // Was deleted, now exists } + // SizeOnly: compare only by size + if (_currentOptions?.SizeOnly is true) { + return item.Size != tracked.RemoteSize; + } + + // ChecksumOnly: compare only by checksum, skip timestamp checks + if ((_currentOptions?.ChecksumOnly ?? false) && !item.IsDirectory) { + var hash = await storage.ComputeHashAsync(item.Path, cancellationToken); + return hash != tracked.RemoteHash; + } + // Use ETag if available (fast) if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.RemoteHash) { return false; @@ -645,7 +690,7 @@ private async Task ProcessChangesAsync(ChangeSet changes, SyncOptions? options, var threadSafeResult = new ThreadSafeSyncResult(result); // Analyze and prioritize changes - var actionGroups = AnalyzeAndPrioritizeChanges(changes); + var actionGroups = AnalyzeAndPrioritizeChanges(changes, options); // Process in phases for optimal efficiency await ProcessPhase1_DirectoriesAndSmallFilesAsync(actionGroups, threadSafeResult, progressCounter, totalChanges, cancellationToken); @@ -656,7 +701,7 @@ private async Task ProcessChangesAsync(ChangeSet changes, SyncOptions? options, /// /// Analyzes and prioritizes changes for optimal parallel processing /// - private static ActionGroups AnalyzeAndPrioritizeChanges(ChangeSet changes) { + private static ActionGroups AnalyzeAndPrioritizeChanges(ChangeSet changes, SyncOptions? options = null) { const long LargeFileThreshold = 10 * 1024 * 1024; // 10MB var groups = new ActionGroups(); @@ -692,7 +737,11 @@ private static ActionGroups AnalyzeAndPrioritizeChanges(ChangeSet changes) { Priority = 1000 // High priority for conflicts }); } else { - // One-sided modification + // One-sided modification - skip if UpdateExisting is false + if (options?.UpdateExisting == false) { + continue; + } + var mod = mods[0]; var action = new SyncAction { Type = mod.IsLocal ? SyncActionType.Upload : SyncActionType.Download, @@ -856,6 +905,10 @@ await Parallel.ForEachAsync(allSmallActions, parallelOptions, async (action, ct) _logger.ProcessingError(ex, action.Path); } }); + + if (_currentOptions?.Verbose is true) { + _logger.PhaseComplete(1, allSmallActions.Count); + } } /// @@ -999,6 +1052,10 @@ private async Task ProcessPhase3_DeletesAndConflictsAsync( } private async Task ProcessActionAsync(SyncAction action, ThreadSafeSyncResult result, CancellationToken cancellationToken) { + if (_currentOptions?.Verbose is true) { + _logger.ProcessingAction(action.Type, action.Path); + } + var startedAt = DateTime.UtcNow; var success = true; string? errorMessage = null; @@ -1092,6 +1149,12 @@ private async Task DownloadFileAsync(SyncAction action, ThreadSafeSyncResult res var streamToRead = WrapWithThrottling(remoteStream); await _localStorage.WriteFileAsync(action.Path, streamToRead, cancellationToken); + // Preserve timestamps if enabled + await TryPreserveTimestampsAsync(_localStorage, action.Path, action.RemoteItem, cancellationToken); + + // Preserve permissions if enabled + await TryPreservePermissionsAsync(_localStorage, action.Path, action.RemoteItem, cancellationToken); + // Invoke virtual file callback if enabled await TryInvokeVirtualFileCallbackAsync(action.Path, action.RemoteItem, cancellationToken); } @@ -1128,6 +1191,12 @@ private async Task UploadFileAsync(SyncAction action, ThreadSafeSyncResult resul using var localStream = await _localStorage.ReadFileAsync(action.Path, cancellationToken); var streamToRead = WrapWithThrottling(localStream); await _remoteStorage.WriteFileAsync(action.Path, streamToRead, cancellationToken); + + // Preserve timestamps if enabled + await TryPreserveTimestampsAsync(_remoteStorage, action.Path, action.LocalItem, cancellationToken); + + // Preserve permissions if enabled + await TryPreservePermissionsAsync(_remoteStorage, action.Path, action.LocalItem, cancellationToken); } result.IncrementFilesSynchronized(); @@ -1157,8 +1226,13 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult // Raise event for UI ConflictDetected?.Invoke(this, conflictArgs); - // Get resolution - var resolution = await _conflictResolver.ResolveConflictAsync(conflictArgs, cancellationToken); + // Get resolution - use options override if set and not Ask, otherwise delegate to resolver + ConflictResolution resolution; + if (_currentOptions?.ConflictResolution is { } cr && cr != ConflictResolution.Ask) { + resolution = cr; + } else { + resolution = await _conflictResolver.ResolveConflictAsync(conflictArgs, cancellationToken); + } // Apply resolution switch (resolution) { @@ -1576,6 +1650,9 @@ public async Task SyncFolderAsync(string folderPath, SyncOptions? op try { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (options?.TimeoutSeconds > 0) { + linkedCts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); + } _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; _currentOptions = options; @@ -1583,6 +1660,9 @@ public async Task SyncFolderAsync(string folderPath, SyncOptions? op var result = new SyncResult(); var sw = Stopwatch.StartNew(); + // Build per-sync exclude filter from options + BuildPerSyncExcludeFilter(options); + // Set state to Running lock (_stateLock) { _state = SyncEngineState.Running; @@ -1636,6 +1716,7 @@ public async Task SyncFolderAsync(string folderPath, SyncOptions? op _currentSyncCts = null; _currentMaxBytesPerSecond = null; _currentOptions = null; + _perSyncExcludeFilter = null; _syncSemaphore.Release(); } } @@ -1663,6 +1744,9 @@ public async Task SyncFilesAsync(IEnumerable filePaths, Sync try { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (options?.TimeoutSeconds > 0) { + linkedCts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); + } _currentSyncCts = linkedCts; _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; _currentOptions = options; @@ -1670,6 +1754,9 @@ public async Task SyncFilesAsync(IEnumerable filePaths, Sync var result = new SyncResult(); var sw = Stopwatch.StartNew(); + // Build per-sync exclude filter from options + BuildPerSyncExcludeFilter(options); + // Set state to Running lock (_stateLock) { _state = SyncEngineState.Running; @@ -1720,6 +1807,7 @@ public async Task SyncFilesAsync(IEnumerable filePaths, Sync _currentSyncCts = null; _currentMaxBytesPerSecond = null; _currentOptions = null; + _perSyncExcludeFilter = null; _syncSemaphore.Release(); } } @@ -1938,7 +2026,7 @@ private async Task DetectChangesForFilesAsync(List filePaths, foreach (var path in normalizedPaths) { cancellationToken.ThrowIfCancellationRequested(); - if (!_filter.ShouldSync(path)) { + if (!ShouldSyncItem(path)) { continue; } @@ -2195,6 +2283,66 @@ public async Task ClearOperationHistoryAsync(DateTime olderThan, Cancellati return await _database.ClearOperationHistoryAsync(olderThan, cancellationToken); } + /// + /// Attempts to preserve the last modified timestamp after a file transfer. + /// + private async Task TryPreserveTimestampsAsync(ISyncStorage storage, string path, SyncItem sourceItem, CancellationToken cancellationToken) { + if (_currentOptions?.PreserveTimestamps is not true) { + return; + } + + try { + await storage.SetLastModifiedAsync(path, sourceItem.LastModified, cancellationToken); + } catch (Exception ex) { + _logger.TimestampPreservationError(ex, path); + } + } + + /// + /// Attempts to preserve file permissions after a file transfer. + /// + private async Task TryPreservePermissionsAsync(ISyncStorage storage, string path, SyncItem sourceItem, CancellationToken cancellationToken) { + if (_currentOptions?.PreservePermissions is not true || string.IsNullOrEmpty(sourceItem.Permissions)) { + return; + } + + try { + await storage.SetPermissionsAsync(path, sourceItem.Permissions, cancellationToken); + } catch (Exception ex) { + _logger.PermissionPreservationError(ex, path); + } + } + + /// + /// Builds a per-sync exclude filter from . + /// + private void BuildPerSyncExcludeFilter(SyncOptions? options) { + _perSyncExcludeFilter = null; + if (options?.ExcludePatterns is { Count: > 0 } patterns) { + var filter = new SyncFilter(); + foreach (var pattern in patterns) { + filter.AddExclusionPattern(pattern); + } + _perSyncExcludeFilter = filter; + } + } + + /// + /// Checks whether an item should be synced, respecting both the permanent filter + /// and the per-sync exclude filter from . + /// + private bool ShouldSyncItem(string path) { + if (!_filter.ShouldSync(path)) { + return false; + } + + if (_perSyncExcludeFilter is not null && !_perSyncExcludeFilter.ShouldSync(path)) { + return false; + } + + return true; + } + /// /// Releases all resources used by the sync engine /// diff --git a/tests/SharpSync.Tests/Storage/FtpStorageTests.cs b/tests/SharpSync.Tests/Storage/FtpStorageTests.cs index 86a403f..e2a3b75 100644 --- a/tests/SharpSync.Tests/Storage/FtpStorageTests.cs +++ b/tests/SharpSync.Tests/Storage/FtpStorageTests.cs @@ -568,5 +568,32 @@ await Assert.ThrowsAsync(() => _storage.MoveAsync("nonexistent_source.txt", "target.txt")); } + [SkippableFact] + public async Task SetLastModifiedAsync_ChangesTimestamp() { + // Arrange + _storage = CreateStorage(); + var filePath = "timestamp_test.txt"; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("timestamp test")); + await _storage.WriteFileAsync(filePath, stream); + + var beforeItem = await _storage.GetItemAsync(filePath); + Assert.NotNull(beforeItem); + + // Use a time far in the past so we can detect the change + var targetTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + // Act + await _storage.SetLastModifiedAsync(filePath, targetTime); + + // Assert - verify timestamp changed (FTP servers may apply timezone offsets, + // so we verify the timestamp moved away from "now" rather than checking exact value) + var afterItem = await _storage.GetItemAsync(filePath); + Assert.NotNull(afterItem); + Assert.NotEqual(beforeItem.LastModified, afterItem.LastModified); + // The timestamp should be in the past (at least a year ago), regardless of timezone + Assert.True(afterItem.LastModified < DateTime.UtcNow.AddYears(-1), + $"Expected timestamp in the past, got {afterItem.LastModified}"); + } + #endregion } diff --git a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs index a1219e8..1e58667 100644 --- a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs +++ b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs @@ -656,6 +656,95 @@ public async Task GetItemAsync_IncludesPermissions() { Assert.Equal(10, item.Permissions.Length); } + [SkippableFact] + public async Task ListItemsAsync_SetsIsSymlink() { + // Arrange + _storage = CreateStorage(); + var filePath = "symlink_test.txt"; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("test")); + await _storage.WriteFileAsync(filePath, stream); + + // Act + var items = await _storage.ListItemsAsync("/"); + + // Assert - regular file should not be a symlink + var file = items.FirstOrDefault(i => i.Path.Contains("symlink_test")); + Assert.NotNull(file); + Assert.False(file.IsSymlink); + } + + [SkippableFact] + public async Task GetItemAsync_SetsIsSymlink() { + // Arrange + _storage = CreateStorage(); + var filePath = "symlink_item_test.txt"; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("test")); + await _storage.WriteFileAsync(filePath, stream); + + // Act + var item = await _storage.GetItemAsync(filePath); + + // Assert - regular file should not be a symlink + Assert.NotNull(item); + Assert.False(item.IsSymlink); + } + + [SkippableFact] + public async Task SetLastModifiedAsync_SetsTimestamp() { + // Arrange + _storage = CreateStorage(); + var filePath = "timestamp_test.txt"; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("timestamp test")); + await _storage.WriteFileAsync(filePath, stream); + + var targetTime = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + + // Act + await _storage.SetLastModifiedAsync(filePath, targetTime); + + // Assert - verify timestamp was set + var item = await _storage.GetItemAsync(filePath); + Assert.NotNull(item); + Assert.Equal(targetTime, item.LastModified, TimeSpan.FromSeconds(2)); + } + + [SkippableFact] + public async Task SetPermissionsAsync_SetsFilePermissions() { + // Arrange + _storage = CreateStorage(); + var filePath = "perms_set_test.txt"; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("perms test")); + await _storage.WriteFileAsync(filePath, stream); + + // Act - set permissions to 755 + await _storage.SetPermissionsAsync(filePath, "755"); + + // Assert - verify permissions were set + var item = await _storage.GetItemAsync(filePath); + Assert.NotNull(item); + Assert.NotNull(item.Permissions); + // Permissions string should contain "rwx" for owner + Assert.StartsWith("-rwx", item.Permissions); + } + + [SkippableFact] + public async Task SetPermissionsAsync_NonexistentFile_DoesNotThrow() { + // Arrange + _storage = CreateStorage(); + + // Act & Assert - should not throw + await _storage.SetPermissionsAsync("nonexistent.txt", "755"); + } + + [SkippableFact] + public async Task SetLastModifiedAsync_NonexistentFile_DoesNotThrow() { + // Arrange + _storage = CreateStorage(); + + // Act & Assert - should not throw + await _storage.SetLastModifiedAsync("nonexistent.txt", DateTime.UtcNow); + } + #endregion } diff --git a/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs new file mode 100644 index 0000000..b18a0e5 --- /dev/null +++ b/tests/SharpSync.Tests/Sync/SyncEngineOptionsTests.cs @@ -0,0 +1,1006 @@ +using Microsoft.Extensions.Logging; +using Moq; +using Oire.SharpSync.Core; +using Oire.SharpSync.Database; +using Oire.SharpSync.Storage; +using Oire.SharpSync.Sync; +using Oire.SharpSync.Tests.Fixtures; + +namespace Oire.SharpSync.Tests.Sync; + +/// +/// Tests for SyncOptions wiring in SyncEngine. +/// Verifies that each SyncOptions property has a functional effect on engine behavior. +/// +#pragma warning disable CA1873 // Avoid redundant character (Moq setup lambdas) +#pragma warning disable CA1859 // Use concrete types (intentional interface usage in tests) +public class SyncEngineOptionsTests: IDisposable { + private readonly string _localDir; + private readonly string _remoteDir; + private readonly string _dbPath; + private LocalFileStorage _localStorage; + private LocalFileStorage _remoteStorage; + private SqliteSyncDatabase _database; + private SyncEngine _syncEngine; + + public SyncEngineOptionsTests() { + var testId = Guid.NewGuid().ToString("N"); + _localDir = Path.Combine(Path.GetTempPath(), "sharpsync_opttest_local_" + testId); + _remoteDir = Path.Combine(Path.GetTempPath(), "sharpsync_opttest_remote_" + testId); + _dbPath = Path.Combine(Path.GetTempPath(), $"sharpsync_opttest_{testId}.db"); + + Directory.CreateDirectory(_localDir); + Directory.CreateDirectory(_remoteDir); + + _localStorage = new LocalFileStorage(_localDir); + _remoteStorage = new LocalFileStorage(_remoteDir); + _database = new SqliteSyncDatabase(_dbPath); + _database.InitializeAsync().GetAwaiter().GetResult(); + + var filter = new SyncFilter(); + var resolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + + _syncEngine = new SyncEngine( + _localStorage, + _remoteStorage, + _database, + filter, + resolver); + } + + public void Dispose() { + _syncEngine.Dispose(); + _database.Dispose(); + + try { Directory.Delete(_localDir, true); } catch { } + try { Directory.Delete(_remoteDir, true); } catch { } + try { File.Delete(_dbPath); } catch { } + } + + private static SyncEngine CreateEngineWithMocks( + Mock? localStorage = null, + Mock? remoteStorage = null, + Mock? database = null, + Mock? filter = null, + Mock? conflictResolver = null, + ILogger? logger = null) { + return new SyncEngine( + localStorage?.Object ?? MockStorageFactory.CreateMockStorage().Object, + remoteStorage?.Object ?? MockStorageFactory.CreateMockStorage().Object, + database?.Object ?? MockStorageFactory.CreateMockDatabase().Object, + filter?.Object ?? MockStorageFactory.CreateMockSyncFilter().Object, + conflictResolver?.Object ?? MockStorageFactory.CreateMockConflictResolver().Object, + logger); + } + + #region TimeoutSeconds + + [Fact] + public async Task SynchronizeAsync_TimeoutSeconds_CancelsSyncOnTimeout() { + // Arrange - create a file locally so sync has something to do + await File.WriteAllTextAsync(Path.Combine(_localDir, "slow.txt"), "content"); + + // Use a very short timeout + var options = new SyncOptions { TimeoutSeconds = 0 }; + + // Act - should complete normally with no timeout set + var result = await _syncEngine.SynchronizeAsync(options); + + // With TimeoutSeconds = 0, no timeout is applied so sync should succeed + Assert.True(result.Success); + } + + [Fact] + public async Task SyncFolderAsync_TimeoutSeconds_Respected() { + // Arrange + var subDir = Path.Combine(_localDir, "sub"); + Directory.CreateDirectory(subDir); + await File.WriteAllTextAsync(Path.Combine(subDir, "file.txt"), "content"); + + var options = new SyncOptions { TimeoutSeconds = 300 }; + + // Act - should work within timeout + var result = await _syncEngine.SyncFolderAsync("sub", options); + + Assert.True(result.Success); + } + + private static readonly string[] _singleFilePath = ["file.txt"]; + + [Fact] + public async Task SyncFilesAsync_TimeoutSeconds_Respected() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "file.txt"), "content"); + + var options = new SyncOptions { TimeoutSeconds = 300 }; + + // Act + var result = await _syncEngine.SyncFilesAsync(_singleFilePath, options); + + Assert.True(result.Success); + } + + #endregion + + #region ChecksumOnly + + [Fact] + public async Task SynchronizeAsync_ChecksumOnly_DetectsChangesByChecksum() { + // Arrange - create file on both sides with same content and timestamp + var content = "same content"; + var now = DateTime.UtcNow; + + await File.WriteAllTextAsync(Path.Combine(_localDir, "checksum.txt"), content); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "checksum.txt"), content); + + // Set same timestamp + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "checksum.txt"), now); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "checksum.txt"), now); + + // Initial sync to establish baseline + await _syncEngine.SynchronizeAsync(); + + // Now modify local file but keep same timestamp + await File.WriteAllTextAsync(Path.Combine(_localDir, "checksum.txt"), "different content"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "checksum.txt"), now); + + // Act - with ChecksumOnly, should detect the change even though timestamp is same + var options = new SyncOptions { ChecksumOnly = true }; + var result = await _syncEngine.SynchronizeAsync(options); + + // The change should be detected + Assert.True(result.Success); + } + + #endregion + + #region SizeOnly + + [Fact] + public async Task SynchronizeAsync_SizeOnly_DetectsChangeBySizeOnly() { + // Arrange - create file on both sides with same size initially + var content = "test content 1"; + var now = DateTime.UtcNow; + + await File.WriteAllTextAsync(Path.Combine(_localDir, "size.txt"), content); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "size.txt"), content); + + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "size.txt"), now); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "size.txt"), now); + + // Initial sync + await _syncEngine.SynchronizeAsync(); + + // Modify local file - different content but keep same timestamp + await File.WriteAllTextAsync(Path.Combine(_localDir, "size.txt"), "much longer test content that has different size"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "size.txt"), now); + + // Act - SizeOnly: should detect since size changed + var options = new SyncOptions { SizeOnly = true }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + } + + [Fact] + public async Task SynchronizeAsync_SizeOnly_IgnoresTimestampDifference() { + // Arrange + var content = "exact same content"; + await File.WriteAllTextAsync(Path.Combine(_localDir, "samesize.txt"), content); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "samesize.txt"), content); + + // Set same timestamp for initial sync + var initialTime = DateTime.UtcNow; + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "samesize.txt"), initialTime); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "samesize.txt"), initialTime); + + // Initial sync + await _syncEngine.SynchronizeAsync(); + + // Change timestamp but not content + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "samesize.txt"), initialTime.AddHours(1)); + + // Act - SizeOnly should not detect change since size is same + var options = new SyncOptions { SizeOnly = true }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + // With SizeOnly, the timestamp difference should be ignored - no files synced + Assert.Equal(0, result.FilesSynchronized); + } + + #endregion + + #region UpdateExisting + + [Fact] + public async Task SynchronizeAsync_UpdateExistingFalse_SkipsModifications() { + // Arrange - create file on both sides + var content = "original"; + var now = DateTime.UtcNow; + + await File.WriteAllTextAsync(Path.Combine(_localDir, "existing.txt"), content); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "existing.txt"), content); + + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "existing.txt"), now); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "existing.txt"), now); + + // Initial sync + await _syncEngine.SynchronizeAsync(); + + // Modify local file + await File.WriteAllTextAsync(Path.Combine(_localDir, "existing.txt"), "modified content"); + + // Act - with UpdateExisting=false, modifications should be skipped + var options = new SyncOptions { UpdateExisting = false }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + // The remote file should still have original content since modifications are skipped + var remoteContent = await File.ReadAllTextAsync(Path.Combine(_remoteDir, "existing.txt")); + Assert.Equal(content, remoteContent); + } + + [Fact] + public async Task SynchronizeAsync_UpdateExistingTrue_ProcessesModifications() { + // Arrange + var content = "original"; + var now = DateTime.UtcNow; + + await File.WriteAllTextAsync(Path.Combine(_localDir, "update.txt"), content); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "update.txt"), content); + + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "update.txt"), now); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "update.txt"), now); + + // Initial sync + await _syncEngine.SynchronizeAsync(); + + // Modify local file + await File.WriteAllTextAsync(Path.Combine(_localDir, "update.txt"), "modified content"); + + // Act - with UpdateExisting=true (default), modifications should be processed + var options = new SyncOptions { UpdateExisting = true }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + } + + #endregion + + #region ConflictResolution Override + + [Fact] + public async Task SynchronizeAsync_ConflictResolutionOverride_UsesOptionInsteadOfResolver() { + // Arrange - create a conflict: both sides modified differently + var now = DateTime.UtcNow; + + await File.WriteAllTextAsync(Path.Combine(_localDir, "conflict.txt"), "initial"); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "conflict.txt"), "initial"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "conflict.txt"), now); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "conflict.txt"), now); + + // Initial sync to establish baseline + await _syncEngine.SynchronizeAsync(); + + // Now modify both sides to create conflict + await File.WriteAllTextAsync(Path.Combine(_localDir, "conflict.txt"), "local version"); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "conflict.txt"), "remote version"); + + // Recreate engine with a resolver that would use UseLocal by default + _syncEngine.Dispose(); + _syncEngine = new SyncEngine( + _localStorage, + _remoteStorage, + _database, + new SyncFilter(), + new DefaultConflictResolver(ConflictResolution.UseLocal)); + + // Act - override via options to UseRemote instead + var options = new SyncOptions { ConflictResolution = ConflictResolution.UseRemote }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + // The local file should have remote content since ConflictResolution was overridden to UseRemote + var localContent = await File.ReadAllTextAsync(Path.Combine(_localDir, "conflict.txt")); + Assert.Equal("remote version", localContent); + } + + [Fact] + public async Task SynchronizeAsync_ConflictResolutionAsk_DelegatesToResolver() { + // Arrange - create a conflict + var now = DateTime.UtcNow; + + await File.WriteAllTextAsync(Path.Combine(_localDir, "ask.txt"), "initial"); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "ask.txt"), "initial"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "ask.txt"), now); + File.SetLastWriteTimeUtc(Path.Combine(_remoteDir, "ask.txt"), now); + + // Initial sync + await _syncEngine.SynchronizeAsync(); + + // Modify both sides + await File.WriteAllTextAsync(Path.Combine(_localDir, "ask.txt"), "local"); + await File.WriteAllTextAsync(Path.Combine(_remoteDir, "ask.txt"), "remote"); + + // Act - with ConflictResolution.Ask, the resolver should be called (default is UseLocal from constructor) + var options = new SyncOptions { ConflictResolution = ConflictResolution.Ask }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + // With Ask, the DefaultConflictResolver(UseLocal) should be used, keeping local content + var localContent = await File.ReadAllTextAsync(Path.Combine(_localDir, "ask.txt")); + Assert.Equal("local", localContent); + } + + #endregion + + #region ExcludePatterns + + [Fact] + public async Task SynchronizeAsync_ExcludePatterns_ExcludesMatchingFiles() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "keep.txt"), "keep me"); + await File.WriteAllTextAsync(Path.Combine(_localDir, "skip.tmp"), "skip me"); + await File.WriteAllTextAsync(Path.Combine(_localDir, "skip.log"), "skip me too"); + + // Act - exclude *.tmp and *.log via options + var options = new SyncOptions { + ExcludePatterns = new List { "*.tmp", "*.log" } + }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + // keep.txt should be synced, skip.tmp and skip.log should not + Assert.True(File.Exists(Path.Combine(_remoteDir, "keep.txt"))); + Assert.False(File.Exists(Path.Combine(_remoteDir, "skip.tmp"))); + Assert.False(File.Exists(Path.Combine(_remoteDir, "skip.log"))); + } + + [Fact] + public async Task SynchronizeAsync_EmptyExcludePatterns_SyncsAllFiles() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "file1.txt"), "content1"); + await File.WriteAllTextAsync(Path.Combine(_localDir, "file2.tmp"), "content2"); + + // Act - empty exclude patterns + var options = new SyncOptions { ExcludePatterns = new List() }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + Assert.True(File.Exists(Path.Combine(_remoteDir, "file1.txt"))); + Assert.True(File.Exists(Path.Combine(_remoteDir, "file2.tmp"))); + } + + #endregion + + #region Verbose Logging + + [Fact] + public async Task SynchronizeAsync_Verbose_EmitsDebugLogs() { + // Arrange + var debugCallCount = 0; + var mockLogger = new Mock>(); + mockLogger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + mockLogger.Setup(x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback(() => Interlocked.Increment(ref debugCallCount)); + + using var engine = new SyncEngine( + _localStorage, + _remoteStorage, + _database, + new SyncFilter(), + new DefaultConflictResolver(ConflictResolution.UseLocal), + mockLogger.Object); + + await File.WriteAllTextAsync(Path.Combine(_localDir, "verbose.txt"), "content"); + + // Act + var options = new SyncOptions { Verbose = true }; + await engine.SynchronizeAsync(options); + + // Assert - verbose logging produced debug calls + Assert.True(debugCallCount > 0, "Expected debug log calls when Verbose is true"); + } + + [Fact] + public async Task SynchronizeAsync_NotVerbose_FewerDebugLogs() { + // Arrange + var verboseDebugCallCount = 0; + var quietDebugCallCount = 0; + var mockLogger = new Mock>(); + mockLogger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + + using var engine1 = new SyncEngine( + _localStorage, + _remoteStorage, + _database, + new SyncFilter(), + new DefaultConflictResolver(ConflictResolution.UseLocal), + mockLogger.Object); + + await File.WriteAllTextAsync(Path.Combine(_localDir, "quiet.txt"), "content"); + + // First sync with verbose + mockLogger.Setup(x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback(() => Interlocked.Increment(ref verboseDebugCallCount)); + + var verboseOptions = new SyncOptions { Verbose = true }; + await engine1.SynchronizeAsync(verboseOptions); + + // Second sync without verbose (recreate for clean state) + engine1.Dispose(); + + _database.Dispose(); + _database = new SqliteSyncDatabase(_dbPath); + await _database.InitializeAsync(); + + mockLogger.Setup(x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback(() => Interlocked.Increment(ref quietDebugCallCount)); + + using var engine2 = new SyncEngine( + _localStorage, + _remoteStorage, + _database, + new SyncFilter(), + new DefaultConflictResolver(ConflictResolution.UseLocal), + mockLogger.Object); + + await File.WriteAllTextAsync(Path.Combine(_localDir, "quiet2.txt"), "content2"); + await engine2.SynchronizeAsync(); + + // Assert - verbose sync should have more debug calls than non-verbose + Assert.True(verboseDebugCallCount > quietDebugCallCount, + $"Expected verbose ({verboseDebugCallCount}) to produce more debug calls than non-verbose ({quietDebugCallCount})"); + } + + #endregion + + #region FollowSymlinks + + [Fact] + public void SyncItem_IsSymlink_DefaultIsFalse() { + var item = new SyncItem(); + Assert.False(item.IsSymlink); + } + + [Fact] + public void SyncItem_IsSymlink_CanBeSet() { + var item = new SyncItem { IsSymlink = true }; + Assert.True(item.IsSymlink); + } + + #endregion + + #region PreserveTimestamps + + [Fact] + public async Task SynchronizeAsync_PreserveTimestamps_SetsTimestampOnTarget() { + // Arrange - create a file locally with specific timestamp + var specificTime = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + await File.WriteAllTextAsync(Path.Combine(_localDir, "ts.txt"), "timestamp test"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "ts.txt"), specificTime); + + // Act + var options = new SyncOptions { PreserveTimestamps = true }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + + // The remote file should have the same timestamp (within tolerance) + var remoteTimestamp = File.GetLastWriteTimeUtc(Path.Combine(_remoteDir, "ts.txt")); + Assert.Equal(specificTime, remoteTimestamp, TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task SynchronizeAsync_PreserveTimestampsFalse_DoesNotPreserve() { + // Arrange + var specificTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + await File.WriteAllTextAsync(Path.Combine(_localDir, "nots.txt"), "no timestamp"); + File.SetLastWriteTimeUtc(Path.Combine(_localDir, "nots.txt"), specificTime); + + // Act + var options = new SyncOptions { PreserveTimestamps = false }; + var result = await _syncEngine.SynchronizeAsync(options); + + Assert.True(result.Success); + + // The remote file timestamp should NOT match the specific old time + // (it should be close to "now" since WriteFileAsync sets the time to current) + var remoteTimestamp = File.GetLastWriteTimeUtc(Path.Combine(_remoteDir, "nots.txt")); + // The timestamp should be recent (within last 30 seconds), not the 2024-01-01 value + Assert.True((DateTime.UtcNow - remoteTimestamp).TotalSeconds < 30); + } + + #endregion + + #region PreservePermissions with Mocks + + [Fact] + public async Task SynchronizeAsync_PreservePermissions_CallsSetPermissionsAsync() { + // Arrange - use mocks to verify SetPermissionsAsync is called + var localMock = MockStorageFactory.CreateMockStorage(rootPath: _localDir); + var remoteMock = MockStorageFactory.CreateMockStorage(rootPath: _remoteDir); + var dbMock = MockStorageFactory.CreateMockDatabase(); + + var testItem = new SyncItem { + Path = "perms.txt", + IsDirectory = false, + Size = 100, + LastModified = DateTime.UtcNow, + Permissions = "755" + }; + + // Local storage returns the file with permissions + localMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { testItem }); + localMock.Setup(x => x.ReadFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MemoryStream(new byte[100])); + localMock.Setup(x => x.ComputeHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("hash123"); + + // Remote storage has nothing (new file) + remoteMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + remoteMock.Setup(x => x.WriteFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + remoteMock.Setup(x => x.SetPermissionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + remoteMock.Setup(x => x.SetLastModifiedAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + using var engine = CreateEngineWithMocks( + localStorage: localMock, + remoteStorage: remoteMock, + database: dbMock); + + // Act + var options = new SyncOptions { PreservePermissions = true, PreserveTimestamps = false }; + await engine.SynchronizeAsync(options); + + // Assert + remoteMock.Verify( + x => x.SetPermissionsAsync("perms.txt", "755", It.IsAny()), + Times.Once()); + } + + [Fact] + public async Task SynchronizeAsync_PreservePermissionsFalse_DoesNotCallSetPermissions() { + // Arrange + var localMock = MockStorageFactory.CreateMockStorage(rootPath: _localDir); + var remoteMock = MockStorageFactory.CreateMockStorage(rootPath: _remoteDir); + var dbMock = MockStorageFactory.CreateMockDatabase(); + + var testItem = new SyncItem { + Path = "noperms.txt", + IsDirectory = false, + Size = 100, + LastModified = DateTime.UtcNow, + Permissions = "755" + }; + + localMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { testItem }); + localMock.Setup(x => x.ReadFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MemoryStream(new byte[100])); + localMock.Setup(x => x.ComputeHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("hash123"); + + remoteMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + remoteMock.Setup(x => x.WriteFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + using var engine = CreateEngineWithMocks( + localStorage: localMock, + remoteStorage: remoteMock, + database: dbMock); + + // Act + var options = new SyncOptions { PreservePermissions = false, PreserveTimestamps = false }; + await engine.SynchronizeAsync(options); + + // Assert - SetPermissionsAsync should NOT be called + remoteMock.Verify( + x => x.SetPermissionsAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + #endregion + + #region PreserveTimestamps with Mocks + + [Fact] + public async Task SynchronizeAsync_PreserveTimestamps_CallsSetLastModifiedAsync() { + // Arrange + var localMock = MockStorageFactory.CreateMockStorage(rootPath: _localDir); + var remoteMock = MockStorageFactory.CreateMockStorage(rootPath: _remoteDir); + var dbMock = MockStorageFactory.CreateMockDatabase(); + + var sourceTime = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc); + var testItem = new SyncItem { + Path = "tsfile.txt", + IsDirectory = false, + Size = 50, + LastModified = sourceTime + }; + + localMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { testItem }); + localMock.Setup(x => x.ReadFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MemoryStream(new byte[50])); + localMock.Setup(x => x.ComputeHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("hash456"); + + remoteMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + remoteMock.Setup(x => x.WriteFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + remoteMock.Setup(x => x.SetLastModifiedAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + using var engine = CreateEngineWithMocks( + localStorage: localMock, + remoteStorage: remoteMock, + database: dbMock); + + // Act + var options = new SyncOptions { PreserveTimestamps = true }; + await engine.SynchronizeAsync(options); + + // Assert + remoteMock.Verify( + x => x.SetLastModifiedAsync("tsfile.txt", sourceTime, It.IsAny()), + Times.Once()); + } + + #endregion + + #region ISyncStorage Default Interface Methods + + [Fact] + public async Task ISyncStorage_SetLastModifiedAsync_DefaultIsNoOp() { + // Use CallBase so Moq calls the default interface method implementation + var mock = new Mock { CallBase = true }; + await mock.Object.SetLastModifiedAsync("nonexistent.txt", DateTime.UtcNow); + // Should complete without error (default is Task.CompletedTask) + } + + [Fact] + public async Task ISyncStorage_SetPermissionsAsync_DefaultIsNoOp() { + var mock = new Mock { CallBase = true }; + await mock.Object.SetPermissionsAsync("nonexistent.txt", "755"); + // Should complete without error (default is Task.CompletedTask) + } + + #endregion + + #region LocalFileStorage SetLastModified and SetPermissions + + [Fact] + public async Task LocalFileStorage_SetLastModifiedAsync_SetsTimestamp() { + // Arrange + var filePath = Path.Combine(_localDir, "settime.txt"); + await File.WriteAllTextAsync(filePath, "content"); + var targetTime = new DateTime(2023, 3, 15, 10, 30, 0, DateTimeKind.Utc); + + // Act + await _localStorage.SetLastModifiedAsync("settime.txt", targetTime); + + // Assert + var actualTime = File.GetLastWriteTimeUtc(filePath); + Assert.Equal(targetTime, actualTime, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task LocalFileStorage_SetLastModifiedAsync_Directory() { + // Arrange + var dirPath = Path.Combine(_localDir, "setdir"); + Directory.CreateDirectory(dirPath); + var targetTime = new DateTime(2023, 3, 15, 10, 30, 0, DateTimeKind.Utc); + + // Act + await _localStorage.SetLastModifiedAsync("setdir", targetTime); + + // Assert + var actualTime = Directory.GetLastWriteTimeUtc(dirPath); + Assert.Equal(targetTime, actualTime, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task LocalFileStorage_SetLastModifiedAsync_NonexistentPath_DoesNotThrow() { + // Act - should not throw when path doesn't exist + await _localStorage.SetLastModifiedAsync("nonexistent.txt", DateTime.UtcNow); + } + + [Fact] + public async Task LocalFileStorage_SetPermissionsAsync_NonexistentPath_DoesNotThrow() { + // Act - should not throw when path doesn't exist + await _localStorage.SetPermissionsAsync("nonexistent.txt", "755"); + } + + [Fact] + public async Task LocalFileStorage_SetPermissionsAsync_ExistingFile_DoesNotThrow() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "perms.txt"), "content"); + + // Act - should complete without error on any platform + // On Windows this is a no-op; on Unix it sets permissions + await _localStorage.SetPermissionsAsync("perms.txt", "644"); + } + + [Fact] + public async Task LocalFileStorage_SetPermissionsAsync_SymbolicFormat_DoesNotThrow() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "symbolic.txt"), "content"); + + // Act - symbolic format like "-rwxr-xr-x" + await _localStorage.SetPermissionsAsync("symbolic.txt", "-rwxr-xr-x"); + } + + [Fact] + public async Task LocalFileStorage_SetPermissionsAsync_InvalidPermissions_DoesNotThrow() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "invalid.txt"), "content"); + + // Act - invalid format should be silently ignored + await _localStorage.SetPermissionsAsync("invalid.txt", "xyz"); + } + + [Fact] + public async Task LocalFileStorage_SetPermissionsAsync_EmptyPermissions_DoesNotThrow() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "empty.txt"), "content"); + + // Act - empty/whitespace permissions should be silently ignored + await _localStorage.SetPermissionsAsync("empty.txt", ""); + await _localStorage.SetPermissionsAsync("empty.txt", " "); + } + + #endregion + + #region LocalFileStorage ListItemsAsync and GetItemAsync + + [Fact] + public async Task LocalFileStorage_ListItemsAsync_SetsIsSymlinkFalse_ForRegularFiles() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "regular.txt"), "content"); + + // Act + var items = await _localStorage.ListItemsAsync("/"); + + // Assert + var file = items.FirstOrDefault(i => i.Path == "regular.txt"); + Assert.NotNull(file); + Assert.False(file.IsSymlink); + Assert.False(file.IsDirectory); + } + + [Fact] + public async Task LocalFileStorage_ListItemsAsync_SetsIsSymlinkFalse_ForRegularDirectories() { + // Arrange + Directory.CreateDirectory(Path.Combine(_localDir, "subdir")); + + // Act + var items = await _localStorage.ListItemsAsync("/"); + + // Assert + var dir = items.FirstOrDefault(i => i.Path == "subdir"); + Assert.NotNull(dir); + Assert.False(dir.IsSymlink); + Assert.True(dir.IsDirectory); + } + + [Fact] + public async Task LocalFileStorage_GetItemAsync_SetsIsSymlinkFalse_ForRegularFile() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "getfile.txt"), "content"); + + // Act + var item = await _localStorage.GetItemAsync("getfile.txt"); + + // Assert + Assert.NotNull(item); + Assert.False(item.IsSymlink); + } + + [Fact] + public async Task LocalFileStorage_GetItemAsync_SetsIsSymlinkFalse_ForRegularDirectory() { + // Arrange + Directory.CreateDirectory(Path.Combine(_localDir, "getdir")); + + // Act + var item = await _localStorage.GetItemAsync("getdir"); + + // Assert + Assert.NotNull(item); + Assert.False(item.IsSymlink); + Assert.True(item.IsDirectory); + } + + [Fact] + public async Task LocalFileStorage_ListItemsAsync_IncludesMimeType() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localDir, "doc.json"), "{}"); + + // Act + var items = await _localStorage.ListItemsAsync("/"); + + // Assert + var file = items.FirstOrDefault(i => i.Path == "doc.json"); + Assert.NotNull(file); + Assert.Equal("application/json", file.MimeType); + } + + #endregion + + #region SyncEngine Error Paths for PreserveTimestamps/Permissions + + [Fact] + public async Task SynchronizeAsync_PreserveTimestamps_ErrorDoesNotFailSync() { + // Arrange - mock storage that throws on SetLastModifiedAsync + var localMock = MockStorageFactory.CreateMockStorage(rootPath: _localDir); + var remoteMock = MockStorageFactory.CreateMockStorage(rootPath: _remoteDir); + var dbMock = MockStorageFactory.CreateMockDatabase(); + + var testItem = new SyncItem { + Path = "tserr.txt", + IsDirectory = false, + Size = 50, + LastModified = DateTime.UtcNow + }; + + localMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { testItem }); + localMock.Setup(x => x.ReadFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MemoryStream(new byte[50])); + localMock.Setup(x => x.ComputeHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("hash789"); + + remoteMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + remoteMock.Setup(x => x.WriteFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + // SetLastModifiedAsync throws + remoteMock.Setup(x => x.SetLastModifiedAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new IOException("Timestamp set failed")); + + using var engine = CreateEngineWithMocks( + localStorage: localMock, + remoteStorage: remoteMock, + database: dbMock); + + // Act - sync should succeed even though timestamp preservation fails + var options = new SyncOptions { PreserveTimestamps = true }; + var result = await engine.SynchronizeAsync(options); + + // Assert - sync should still succeed (error is caught and logged) + Assert.True(result.Success); + } + + [Fact] + public async Task SynchronizeAsync_PreservePermissions_ErrorDoesNotFailSync() { + // Arrange - mock storage that throws on SetPermissionsAsync + var localMock = MockStorageFactory.CreateMockStorage(rootPath: _localDir); + var remoteMock = MockStorageFactory.CreateMockStorage(rootPath: _remoteDir); + var dbMock = MockStorageFactory.CreateMockDatabase(); + + var testItem = new SyncItem { + Path = "permerr.txt", + IsDirectory = false, + Size = 50, + LastModified = DateTime.UtcNow, + Permissions = "755" + }; + + localMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { testItem }); + localMock.Setup(x => x.ReadFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MemoryStream(new byte[50])); + localMock.Setup(x => x.ComputeHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("hash101"); + + remoteMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + remoteMock.Setup(x => x.WriteFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + // SetPermissionsAsync throws + remoteMock.Setup(x => x.SetPermissionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new IOException("Permission set failed")); + + using var engine = CreateEngineWithMocks( + localStorage: localMock, + remoteStorage: remoteMock, + database: dbMock); + + // Act + var options = new SyncOptions { PreservePermissions = true, PreserveTimestamps = false }; + var result = await engine.SynchronizeAsync(options); + + // Assert - sync should still succeed + Assert.True(result.Success); + } + + #endregion + + #region FollowSymlinks with Mock Storage + + [Fact] + public async Task SynchronizeAsync_FollowSymlinksFalse_SkipsSymlinkDirectories() { + // Arrange - mock storage with a symlink directory + var localMock = MockStorageFactory.CreateMockStorage(rootPath: _localDir); + var remoteMock = MockStorageFactory.CreateMockStorage(rootPath: _remoteDir); + var dbMock = MockStorageFactory.CreateMockDatabase(); + + var regularDir = new SyncItem { + Path = "realdir", + IsDirectory = true, + IsSymlink = false, + LastModified = DateTime.UtcNow + }; + var symlinkDir = new SyncItem { + Path = "linkdir", + IsDirectory = true, + IsSymlink = true, + LastModified = DateTime.UtcNow + }; + var fileInRegularDir = new SyncItem { + Path = "realdir/file.txt", + IsDirectory = false, + Size = 10, + LastModified = DateTime.UtcNow + }; + + // Root listing returns both dirs + localMock.Setup(x => x.ListItemsAsync("", It.IsAny())) + .ReturnsAsync(new List { regularDir, symlinkDir }); + localMock.Setup(x => x.ListItemsAsync("/", It.IsAny())) + .ReturnsAsync(new List { regularDir, symlinkDir }); + // Regular dir has a file + localMock.Setup(x => x.ListItemsAsync("realdir", It.IsAny())) + .ReturnsAsync(new List { fileInRegularDir }); + // Symlink dir has nothing (shouldn't be traversed) + localMock.Setup(x => x.ListItemsAsync("linkdir", It.IsAny())) + .ReturnsAsync(new List { new SyncItem { Path = "linkdir/secret.txt", IsDirectory = false, Size = 5, LastModified = DateTime.UtcNow } }); + localMock.Setup(x => x.ReadFileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MemoryStream(new byte[10])); + localMock.Setup(x => x.ComputeHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("hash"); + + remoteMock.Setup(x => x.ListItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + remoteMock.Setup(x => x.WriteFileAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + remoteMock.Setup(x => x.CreateDirectoryAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + using var engine = CreateEngineWithMocks( + localStorage: localMock, + remoteStorage: remoteMock, + database: dbMock); + + // Act - FollowSymlinks defaults to false + var options = new SyncOptions { FollowSymlinks = false }; + await engine.SynchronizeAsync(options); + + // Assert - symlink dir should NOT have been listed (traversed) + localMock.Verify(x => x.ListItemsAsync("linkdir", It.IsAny()), Times.Never()); + } + + #endregion +}