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
+}