diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index dbf803e..14e4f06 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -14,6 +14,19 @@ jobs:
runs-on: ubuntu-latest
+ services:
+ sftp:
+ image: atmoz/sftp:latest
+ ports:
+ - 2222:22
+ options: >-
+ --health-cmd "pgrep sshd"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ env:
+ SFTP_USERS: testuser:testpass:1001:100:upload
+
steps:
- uses: actions/checkout@v5
- name: Setup .NET
@@ -28,3 +41,9 @@ jobs:
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
+ env:
+ SFTP_TEST_HOST: localhost
+ SFTP_TEST_PORT: 2222
+ SFTP_TEST_USER: testuser
+ SFTP_TEST_PASS: testpass
+ SFTP_TEST_ROOT: "upload"
diff --git a/CLAUDE.md b/CLAUDE.md
index c716b1c..af1ea73 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,7 +19,7 @@ dotnet build
### Running Tests
```bash
-# Run all tests
+# Run all tests (unit tests only, integration tests skip automatically)
dotnet test
# Run tests with verbose output
@@ -32,6 +32,25 @@ dotnet test tests/SharpSync.Tests/SharpSync.Tests.csproj
dotnet test --logger trx --results-directory TestResults
```
+#### Running Integration Tests
+Integration tests require external services (SFTP server). Use the provided scripts:
+
+```bash
+# Linux/macOS - automatically starts Docker SFTP server
+./scripts/run-integration-tests.sh
+
+# Windows - automatically starts Docker SFTP server
+.\scripts\run-integration-tests.ps1
+
+# Or manually with Docker Compose
+docker-compose -f docker-compose.test.yml up -d
+export SFTP_TEST_HOST=localhost SFTP_TEST_PORT=2222 SFTP_TEST_USER=testuser SFTP_TEST_PASS=testpass SFTP_TEST_ROOT=/home/testuser/upload
+dotnet test --verbosity normal
+docker-compose -f docker-compose.test.yml down
+```
+
+See [TESTING.md](TESTING.md) for detailed testing documentation.
+
### Creating NuGet Package
```bash
# Create NuGet package
@@ -48,6 +67,8 @@ dotnet pack --configuration Release --version-suffix preview
The project uses GitHub Actions for CI/CD. The pipeline currently:
- Builds on Ubuntu only (multi-platform testing planned)
- Runs tests with format checking
+- Includes SFTP integration tests using Docker-based SFTP server
+- Automatically configures test environment variables for integration tests
## High-Level Architecture
@@ -66,7 +87,8 @@ SharpSync is a **pure .NET file synchronization library** with no native depende
2. **Storage Implementations** (`src/SharpSync/Storage/`)
- `LocalFileStorage` - Local filesystem operations (fully implemented and tested)
- `WebDavStorage` - WebDAV with OAuth2, chunking, and platform-specific optimizations (implemented, needs tests)
- - `StorageType` enum includes: SFTP, FTP, S3 (planned for future versions)
+ - `SftpStorage` - SFTP with password and key-based authentication (fully implemented and tested)
+ - `StorageType` enum includes: FTP, S3 (planned for future versions)
3. **Authentication** (`src/SharpSync/Auth/`)
- `IOAuth2Provider` - OAuth2 authentication abstraction (no UI dependencies)
@@ -86,13 +108,14 @@ SharpSync is a **pure .NET file synchronization library** with no native depende
### Key Features
-- **Multi-Protocol Support**: Local and WebDAV storage (extensible to SFTP, FTP, S3)
-- **OAuth2 Authentication**: Full OAuth2 flow support without UI dependencies
+- **Multi-Protocol Support**: Local, WebDAV, and SFTP storage (extensible to FTP, S3)
+- **OAuth2 Authentication**: Full OAuth2 flow support without UI dependencies (WebDAV)
+- **SSH Key & Password Auth**: Secure SFTP authentication with private keys or passwords
- **Smart Conflict Resolution**: Rich conflict analysis for UI integration
- **Selective Sync**: Include/exclude patterns for files and folders
- **Progress Reporting**: Real-time progress events for UI binding
- **Large File Support**: Chunked uploads with platform-specific optimizations
-- **Network Resilience**: Retry logic and error handling
+- **Network Resilience**: Retry logic and error handling with automatic reconnection
- **Parallel Processing**: Configurable parallelism with intelligent prioritization
### Dependencies
@@ -184,6 +207,7 @@ The core library is production-ready, but several critical items must be address
**Implementations**
- `SyncEngine` - 1,104 lines of production-ready sync logic with three-phase optimization
- `LocalFileStorage` - Fully implemented and tested (557 lines of tests)
+- `SftpStorage` - Fully implemented with password/key auth and tested (650+ lines of tests)
- `SqliteSyncDatabase` - Complete with transaction support and tests
- `SmartConflictResolver` - Intelligent conflict analysis with tests
- `DefaultConflictResolver` - Strategy-based resolution with tests
@@ -205,17 +229,12 @@ The core library is production-ready, but several critical items must be address
- **Fix**: Complete rewrite matching actual architecture
- **File**: `/home/user/sharp-sync/README.md:1-409`
-2. **False SFTP Advertising** โ
- - **Issue**: Package claims SFTP support but it's not implemented
- - **Evidence**:
- - `SharpSync.csproj:18` - Description claims "WebDAV, SFTP, and local storage"
- - `SharpSync.csproj:25` - Tags include "sftp"
- - `SharpSync.csproj:40` - SSH.NET 2025.1.0 dependency included but unused
- - `StorageType.cs:20` - Sftp enum exists with no implementation
- - **Impact**: False advertising, wasted package size (~500KB for SSH.NET)
- - **Fix Options**:
- - **Option A (Recommended)**: Remove SFTP claims and SSH.NET dependency
- - **Option B**: Implement `SftpStorage` (delays release significantly)
+2. **~~False SFTP Advertising~~** โ
**FIXED**
+ - **Status**: SFTP is now fully implemented with comprehensive tests
+ - **Implementation**: `SftpStorage` class with password and key-based authentication
+ - **Tests**: 650+ lines of unit and integration tests
+ - **SSH.NET dependency**: Now properly utilized (version 2025.1.0)
+ - **Result**: Package metadata is now accurate
3. **WebDavStorage Completely Untested** โ
- **Issue**: 812 lines of critical WebDAV code has zero test coverage
@@ -253,9 +272,9 @@ The core library is production-ready, but several critical items must be address
- Add coverlet/codecov integration to CI pipeline
- Track and display test coverage badge
-8. **SSH.NET Dependency Unused**
- - If not implementing SFTP for v1.0, remove dependency
- - Saves ~500KB in package size
+8. **~~SSH.NET Dependency Unused~~** โ
**FIXED**
+ - SSH.NET is now fully utilized by SftpStorage implementation
+ - Dependency is justified and necessary for SFTP support
9. **No Concrete OAuth2Provider Example**
- While intentionally UI-free, a console example would help users
@@ -263,9 +282,10 @@ The core library is production-ready, but several critical items must be address
### ๐ CAN DEFER TO v1.1+
-10. **SFTP/FTP/S3 Implementations**
- - StorageType enum includes these, but they're clearly future features
- - Can be added in future minor versions
+10. **~~SFTP~~/FTP/S3 Implementations** (SFTP โ
DONE)
+ - โ
SFTP now fully implemented with comprehensive tests
+ - FTP/S3 remain future features for v1.1+
+ - StorageType enum prepared for future implementations
11. **Performance Benchmarks**
- BenchmarkDotNet suite for sync operations
@@ -301,20 +321,25 @@ The core library is production-ready, but several critical items must be address
**Minimum Acceptance Criteria:**
- โ
Core sync engine tested (achieved)
-- โ All storage implementations tested (WebDavStorage missing)
+- โ ๏ธ All storage implementations tested (LocalFileStorage โ
, SftpStorage โ
, WebDavStorage โ)
- โ README matches actual API (completely wrong)
- โ
No TODOs/FIXMEs in code (achieved)
- โ Examples directory exists (missing)
-- โ ๏ธ Package metadata accurate (SFTP claims false)
+- โ
Package metadata accurate (SFTP now implemented!)
+- โ
Integration test infrastructure (Docker-based CI testing)
-**Current Score: 3/9 (33%)**
+**Current Score: 5/9 (56%)** - Improved from 33%!
### ๐ฏ Post-v1.0 Roadmap (Future Versions)
+**v1.0** โ
SFTP Implemented!
+- โ
SFTP storage implementation (DONE!)
+- โ
Integration test infrastructure with Docker (DONE!)
+
**v1.1**
-- SFTP storage implementation
- Code coverage reporting
- Performance benchmarks
+- Multi-platform CI testing (Windows, macOS)
**v1.2**
- FTP/FTPS storage implementation
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..8c2e811
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,236 @@
+# Testing Guide
+
+This document describes how to run tests for SharpSync, including integration tests that require external services.
+
+## Quick Start
+
+```bash
+# Run all unit tests (no external services required)
+dotnet test
+
+# Run with verbose output
+dotnet test --verbosity normal
+```
+
+## Integration Tests
+
+SharpSync includes integration tests for storage backends that require real servers (e.g., SFTP). These tests are automatically skipped unless you provide the necessary configuration.
+
+### SFTP Integration Tests
+
+SFTP integration tests require a running SFTP server. You have two options:
+
+#### Option 1: Using Docker Compose (Recommended)
+
+The easiest way to run SFTP integration tests locally:
+
+```bash
+# Start SFTP server
+docker-compose -f docker-compose.test.yml up -d
+
+# Wait for server to be ready (about 5 seconds)
+sleep 5
+
+# Run tests with environment variables
+export SFTP_TEST_HOST=localhost
+export SFTP_TEST_PORT=2222
+export SFTP_TEST_USER=testuser
+export SFTP_TEST_PASS=testpass
+export SFTP_TEST_ROOT=/home/testuser/upload
+
+dotnet test --verbosity normal
+
+# Stop SFTP server when done
+docker-compose -f docker-compose.test.yml down
+```
+
+Or use a one-liner:
+
+```bash
+docker-compose -f docker-compose.test.yml up -d && \
+sleep 5 && \
+SFTP_TEST_HOST=localhost SFTP_TEST_PORT=2222 SFTP_TEST_USER=testuser SFTP_TEST_PASS=testpass SFTP_TEST_ROOT=/home/testuser/upload dotnet test --verbosity normal && \
+docker-compose -f docker-compose.test.yml down
+```
+
+#### Option 2: Using Your Own SFTP Server
+
+If you have access to an SFTP server, configure it with these environment variables:
+
+```bash
+export SFTP_TEST_HOST=your-sftp-server.com
+export SFTP_TEST_PORT=22
+export SFTP_TEST_USER=your-username
+export SFTP_TEST_PASS=your-password # For password authentication
+# OR
+export SFTP_TEST_KEY=/path/to/private-key # For key-based authentication
+export SFTP_TEST_ROOT=/path/on/server
+
+dotnet test --verbosity normal
+```
+
+**Note:** The test suite will create and clean up test directories under `SFTP_TEST_ROOT`.
+
+#### Option 3: Using SSH Key Authentication
+
+To test with SSH key authentication:
+
+```bash
+# Generate test key (if needed)
+ssh-keygen -t rsa -b 2048 -f ~/.ssh/sharpsync_test -N ""
+
+# Add key to your SFTP server's authorized_keys
+
+# Run tests
+export SFTP_TEST_HOST=localhost
+export SFTP_TEST_PORT=2222
+export SFTP_TEST_USER=testuser
+export SFTP_TEST_KEY=~/.ssh/sharpsync_test
+export SFTP_TEST_ROOT=/home/testuser/upload
+
+dotnet test --verbosity normal
+```
+
+### WebDAV Integration Tests (Future)
+
+WebDAV integration tests are planned for future releases. They will follow a similar pattern:
+
+```bash
+# Using Docker Compose
+docker-compose -f docker-compose.test.yml up -d webdav
+
+export WEBDAV_TEST_URL=http://localhost:8080/webdav
+export WEBDAV_TEST_USER=testuser
+export WEBDAV_TEST_PASS=testpass
+
+dotnet test --verbosity normal
+```
+
+## 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.
+
+## Test Categories
+
+Tests are organized into the following categories:
+
+### Unit Tests
+- No external dependencies
+- Fast execution
+- Always run
+- Located in `tests/SharpSync.Tests/`
+
+### Integration Tests
+- Require external services (SFTP, WebDAV, etc.)
+- Slower execution
+- Only run when configured
+- Skip automatically if environment variables not set
+
+## Running Specific Tests
+
+```bash
+# Run tests from a specific file
+dotnet test --filter "FullyQualifiedName~SftpStorageTests"
+
+# Run a specific test method
+dotnet test --filter "FullyQualifiedName~SftpStorageTests.TestConnectionAsync_ValidCredentials_ReturnsTrue"
+
+# Run all unit tests (excluding integration tests)
+# Integration tests will skip automatically without environment variables
+dotnet test
+
+# Run only integration tests
+dotnet test --filter "FullyQualifiedName~IntegrationTests"
+```
+
+## Test Coverage
+
+To generate test coverage reports:
+
+```bash
+# Install coverage tool
+dotnet tool install --global dotnet-coverage
+
+# Run tests with coverage
+dotnet test --collect:"XPlat Code Coverage"
+
+# View coverage report
+# Report will be in TestResults/{guid}/coverage.cobertura.xml
+```
+
+## Troubleshooting
+
+### SFTP Tests Skip Automatically
+
+**Problem:** SFTP integration tests show as "Skipped" in test results.
+
+**Solution:** This is expected behavior when environment variables are not set. The tests are designed to skip gracefully rather than fail. Set the required environment variables to enable them.
+
+### SFTP Connection Refused
+
+**Problem:** Tests fail with "Connection refused" error.
+
+**Solution:**
+1. Ensure Docker service is running
+2. Check if port 2222 is already in use: `lsof -i :2222` (on Unix) or `netstat -ano | findstr :2222` (on Windows)
+3. Verify SFTP container is healthy: `docker-compose -f docker-compose.test.yml ps`
+
+### SFTP Authentication Failed
+
+**Problem:** Tests fail with "Authentication failed" error.
+
+**Solution:**
+1. Verify environment variables are set correctly
+2. For key-based auth, ensure the key file exists and has correct permissions: `chmod 600 ~/.ssh/sharpsync_test`
+3. Check if the key is in the correct format (OpenSSH format)
+
+### Tests Timeout
+
+**Problem:** Tests hang or timeout.
+
+**Solution:**
+1. Increase test timeout in test settings
+2. Check network connectivity to SFTP server
+3. Ensure SFTP server is not overloaded
+
+## Writing New Integration Tests
+
+When adding new integration tests:
+
+1. Check for environment variables at the start of the test
+2. Use `SkipIfIntegrationTestsDisabled()` helper method
+3. Clean up resources after test completion
+4. Use unique paths/names to avoid conflicts with parallel test execution
+5. Document required environment variables in test class summary
+
+Example:
+
+```csharp
+[Fact]
+public async Task MyNewIntegrationTest() {
+ SkipIfIntegrationTestsDisabled();
+
+ _storage = CreateStorage();
+
+ // Test code here
+
+ // Cleanup is handled in Dispose()
+}
+```
+
+## 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)
+
+## Additional Resources
+
+- [xUnit Documentation](https://xunit.net/)
+- [Docker Compose Documentation](https://docs.docker.com/compose/)
+- [atmoz/sftp Docker Image](https://github.com/atmoz/sftp)
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 0000000..4750294
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,16 @@
+services:
+ sftp:
+ image: atmoz/sftp:latest
+ ports:
+ - "2222:22"
+ volumes:
+ - sftp-data:/home/testuser/upload
+ command: testuser:testpass:1001:100:upload
+ healthcheck:
+ test: ["CMD", "pgrep", "sshd"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ sftp-data:
diff --git a/scripts/run-integration-tests.ps1 b/scripts/run-integration-tests.ps1
new file mode 100644
index 0000000..cbf4da5
--- /dev/null
+++ b/scripts/run-integration-tests.ps1
@@ -0,0 +1,76 @@
+# Script to run SharpSync integration tests with Docker-based SFTP server
+# Usage: .\scripts\run-integration-tests.ps1 [-TestFilter "filter"]
+
+param(
+ [string]$TestFilter = ""
+)
+
+$ErrorActionPreference = "Stop"
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$ProjectRoot = Split-Path -Parent $ScriptDir
+
+Write-Host "๐ Starting SFTP test server..." -ForegroundColor Cyan
+Set-Location $ProjectRoot
+docker-compose -f docker-compose.test.yml up -d
+
+Write-Host "โณ Waiting for SFTP server to be ready..." -ForegroundColor Yellow
+# Wait up to 60 seconds for the server to be healthy
+$timeout = 60
+$elapsed = 0
+$isHealthy = $false
+
+while ($elapsed -lt $timeout) {
+ $containerStatus = docker-compose -f docker-compose.test.yml ps sftp
+ if ($containerStatus -match "healthy") {
+ Write-Host "โ
SFTP server is ready" -ForegroundColor Green
+ $isHealthy = $true
+ break
+ }
+ Start-Sleep -Seconds 2
+ $elapsed += 2
+ Write-Host " Waiting... ($elapsed`s/$timeout`s)" -ForegroundColor Gray
+}
+
+# Final check
+if (-not $isHealthy) {
+ Write-Host "โ SFTP server failed to become healthy within $timeout`s" -ForegroundColor Red
+ Write-Host "๐ Server logs:" -ForegroundColor Yellow
+ docker-compose -f docker-compose.test.yml logs
+ docker-compose -f docker-compose.test.yml down
+ exit 1
+}
+
+# Set environment variables for tests
+$env:SFTP_TEST_HOST = "localhost"
+$env:SFTP_TEST_PORT = "2222"
+$env:SFTP_TEST_USER = "testuser"
+$env:SFTP_TEST_PASS = "testpass"
+$env:SFTP_TEST_ROOT = "/home/testuser/upload"
+
+Write-Host "๐งช Running tests..." -ForegroundColor Cyan
+
+# Run tests with optional filter
+$testExitCode = 0
+try {
+ if ($TestFilter) {
+ Write-Host " Filter: $TestFilter" -ForegroundColor Gray
+ dotnet test --verbosity normal --filter $TestFilter
+ } else {
+ dotnet test --verbosity normal
+ }
+ $testExitCode = $LASTEXITCODE
+} catch {
+ $testExitCode = 1
+}
+
+Write-Host "๐งน Cleaning up..." -ForegroundColor Cyan
+docker-compose -f docker-compose.test.yml down -v
+
+if ($testExitCode -eq 0) {
+ Write-Host "โ
All tests passed!" -ForegroundColor Green
+} else {
+ Write-Host "โ Tests failed with exit code $testExitCode" -ForegroundColor Red
+}
+
+exit $testExitCode
diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh
new file mode 100755
index 0000000..4f497a7
--- /dev/null
+++ b/scripts/run-integration-tests.sh
@@ -0,0 +1,66 @@
+#!/bin/bash
+
+# Script to run SharpSync integration tests with Docker-based SFTP server
+# Usage: ./scripts/run-integration-tests.sh [test-filter]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+echo "๐ Starting SFTP test server..."
+docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" up -d
+
+echo "โณ Waiting for SFTP server to be ready..."
+# Wait up to 60 seconds for the server to be healthy
+TIMEOUT=60
+ELAPSED=0
+while [ $ELAPSED -lt $TIMEOUT ]; do
+ if docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" ps sftp | grep -q "healthy"; then
+ echo "โ
SFTP server is ready"
+ break
+ fi
+ sleep 2
+ ELAPSED=$((ELAPSED + 2))
+ echo " Waiting... (${ELAPSED}s/${TIMEOUT}s)"
+done
+
+# Final check
+if ! docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" ps sftp | grep -q "healthy"; then
+ echo "โ SFTP server failed to become healthy within ${TIMEOUT}s"
+ echo "๐ Server logs:"
+ docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" logs
+ docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" down
+ exit 1
+fi
+
+# Set environment variables for tests
+export SFTP_TEST_HOST=localhost
+export SFTP_TEST_PORT=2222
+export SFTP_TEST_USER=testuser
+export SFTP_TEST_PASS=testpass
+export SFTP_TEST_ROOT=/home/testuser/upload
+
+echo "๐งช Running tests..."
+cd "$PROJECT_ROOT"
+
+# Run tests with optional filter
+if [ -n "$1" ]; then
+ echo " Filter: $1"
+ dotnet test --verbosity normal --filter "$1"
+else
+ dotnet test --verbosity normal
+fi
+
+TEST_EXIT_CODE=$?
+
+echo "๐งน Cleaning up..."
+docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" down -v
+
+if [ $TEST_EXIT_CODE -eq 0 ]; then
+ echo "โ
All tests passed!"
+else
+ echo "โ Tests failed with exit code $TEST_EXIT_CODE"
+fi
+
+exit $TEST_EXIT_CODE
diff --git a/src/SharpSync/Core/FileConflictEventArgs.cs b/src/SharpSync/Core/FileConflictEventArgs.cs
index 9711a0c..0b7103c 100644
--- a/src/SharpSync/Core/FileConflictEventArgs.cs
+++ b/src/SharpSync/Core/FileConflictEventArgs.cs
@@ -29,6 +29,13 @@ public class FileConflictEventArgs: EventArgs {
///
public ConflictResolution Resolution { get; set; }
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The path of the conflicted file
+ /// The local item (may be null if deleted locally)
+ /// The remote item (may be null if deleted remotely)
+ /// The type of conflict
public FileConflictEventArgs(string path, SyncItem? localItem, SyncItem? remoteItem, ConflictType conflictType) {
Path = path;
LocalItem = localItem;
diff --git a/src/SharpSync/Core/SyncOperation.cs b/src/SharpSync/Core/SyncOperation.cs
index 76bb264..d296e5b 100644
--- a/src/SharpSync/Core/SyncOperation.cs
+++ b/src/SharpSync/Core/SyncOperation.cs
@@ -4,11 +4,38 @@ namespace Oire.SharpSync.Core;
/// Types of sync operations
///
public enum SyncOperation {
+ ///
+ /// Unknown or unspecified operation
+ ///
Unknown,
+
+ ///
+ /// Scanning files and directories to detect changes
+ ///
Scanning,
+
+ ///
+ /// Uploading files to remote storage
+ ///
Uploading,
+
+ ///
+ /// Downloading files from remote storage
+ ///
Downloading,
+
+ ///
+ /// Deleting files or directories
+ ///
Deleting,
+
+ ///
+ /// Creating a directory in storage
+ ///
CreatingDirectory,
+
+ ///
+ /// Resolving a synchronization conflict
+ ///
ResolvingConflict
}
diff --git a/src/SharpSync/Core/SyncProgressEventArgs.cs b/src/SharpSync/Core/SyncProgressEventArgs.cs
index da59e33..3397606 100644
--- a/src/SharpSync/Core/SyncProgressEventArgs.cs
+++ b/src/SharpSync/Core/SyncProgressEventArgs.cs
@@ -4,10 +4,27 @@ namespace Oire.SharpSync.Core;
/// Progress event arguments for sync operations
///
public class SyncProgressEventArgs: EventArgs {
+ ///
+ /// Gets the current progress information
+ ///
public SyncProgress Progress { get; }
+
+ ///
+ /// Gets the path of the item currently being processed, or null if not applicable
+ ///
public string? CurrentItem { get; }
+
+ ///
+ /// Gets the type of operation currently being performed
+ ///
public SyncOperation Operation { get; }
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The current progress information
+ /// The path of the item currently being processed
+ /// The type of operation currently being performed
public SyncProgressEventArgs(SyncProgress progress, string? currentItem = null, SyncOperation operation = SyncOperation.Unknown) {
Progress = progress;
CurrentItem = currentItem;
diff --git a/src/SharpSync/Database/SqliteSyncDatabase.cs b/src/SharpSync/Database/SqliteSyncDatabase.cs
index 44bf36a..65059fb 100644
--- a/src/SharpSync/Database/SqliteSyncDatabase.cs
+++ b/src/SharpSync/Database/SqliteSyncDatabase.cs
@@ -86,6 +86,12 @@ public async Task UpdateSyncStateAsync(SyncState state, CancellationToken cancel
}
}
+ ///
+ /// Deletes the synchronization state for a specific file path
+ ///
+ /// The file path whose sync state should be deleted
+ /// Cancellation token to cancel the operation
+ /// Thrown when the database is not initialized
public async Task DeleteSyncStateAsync(string path, CancellationToken cancellationToken = default) {
EnsureInitialized();
await _connection!.Table()
@@ -93,11 +99,23 @@ public async Task DeleteSyncStateAsync(string path, CancellationToken cancellati
.DeleteAsync();
}
+ ///
+ /// Retrieves all synchronization states from the database
+ ///
+ /// Cancellation token to cancel the operation
+ /// A collection of all sync states
+ /// Thrown when the database is not initialized
public async Task> GetAllSyncStatesAsync(CancellationToken cancellationToken = default) {
EnsureInitialized();
return await _connection!.Table().ToListAsync();
}
+ ///
+ /// Retrieves all synchronization states that require action (not synced or ignored)
+ ///
+ /// Cancellation token to cancel the operation
+ /// A collection of sync states that are pending synchronization
+ /// Thrown when the database is not initialized
public async Task> GetPendingSyncStatesAsync(CancellationToken cancellationToken = default) {
EnsureInitialized();
return await _connection!.Table()
@@ -117,11 +135,22 @@ public async Task BeginTransactionAsync(CancellationToken canc
return new SqliteSyncTransaction(_connection!);
}
+ ///
+ /// Clears all synchronization state records from the database
+ ///
+ /// Cancellation token to cancel the operation
+ /// Thrown when the database is not initialized
public async Task ClearAsync(CancellationToken cancellationToken = default) {
EnsureInitialized();
await _connection!.DeleteAllAsync();
}
+ ///
+ /// Gets statistical information about the synchronization database
+ ///
+ /// Cancellation token to cancel the operation
+ /// Database statistics including item counts, last sync time, and database size
+ /// Thrown when the database is not initialized
public async Task GetStatsAsync(CancellationToken cancellationToken = default) {
EnsureInitialized();
@@ -164,6 +193,13 @@ private void EnsureInitialized() {
}
}
+ ///
+ /// Releases all resources used by the sync database
+ ///
+ ///
+ /// Closes the database connection and disposes of all resources.
+ /// This method can be called multiple times safely. After disposal, the database instance cannot be reused.
+ ///
public void Dispose() {
if (!_disposed) {
_connection?.CloseAsync().Wait();
diff --git a/src/SharpSync/Storage/LocalFileStorage.cs b/src/SharpSync/Storage/LocalFileStorage.cs
index 3eabd44..5619e30 100644
--- a/src/SharpSync/Storage/LocalFileStorage.cs
+++ b/src/SharpSync/Storage/LocalFileStorage.cs
@@ -7,23 +7,40 @@ namespace Oire.SharpSync.Storage;
/// Local filesystem storage implementation
///
public class LocalFileStorage: ISyncStorage {
- private readonly string _rootPath;
-
+ ///
+ /// Gets the storage type (always returns )
+ ///
public StorageType StorageType => StorageType.Local;
- public string RootPath => _rootPath;
+ ///
+ /// Gets the root path on the local filesystem
+ ///
+ public string RootPath { get; }
+
+ ///
+ /// Creates a new local file storage instance
+ ///
+ /// The root directory path for synchronization
+ /// Thrown when rootPath is null or empty
+ /// Thrown when the root path does not exist
public LocalFileStorage(string rootPath) {
if (string.IsNullOrWhiteSpace(rootPath)) {
throw new ArgumentException("Root path cannot be empty", nameof(rootPath));
}
- _rootPath = Path.GetFullPath(rootPath);
+ RootPath = Path.GetFullPath(rootPath);
- if (!Directory.Exists(_rootPath)) {
- throw new DirectoryNotFoundException($"Root path does not exist: {_rootPath}");
+ if (!Directory.Exists(RootPath)) {
+ throw new DirectoryNotFoundException($"Root path does not exist: {RootPath}");
}
}
+ ///
+ /// Lists all items (files and directories) in the specified path
+ ///
+ /// The relative path to list items from
+ /// Cancellation token to cancel the operation
+ /// A collection of sync items representing files and directories
public async Task> ListItemsAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
var items = new List();
@@ -62,6 +79,12 @@ public async Task> ListItemsAsync(string path, Cancellatio
return await Task.FromResult(items);
}
+ ///
+ /// Gets metadata for a specific item (file or directory)
+ ///
+ /// The relative path to the item
+ /// Cancellation token to cancel the operation
+ /// The sync item if it exists, null otherwise
public async Task GetItemAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
@@ -89,6 +112,13 @@ public async Task> ListItemsAsync(string path, Cancellatio
return null;
}
+ ///
+ /// Reads the contents of a file from the local filesystem
+ ///
+ /// The relative path to the file
+ /// Cancellation token to cancel the operation
+ /// A stream containing the file contents
+ /// Thrown when the file does not exist
public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
@@ -99,6 +129,12 @@ public async Task ReadFileAsync(string path, CancellationToken cancellat
return await Task.FromResult(File.OpenRead(fullPath));
}
+ ///
+ /// Writes content to a file on the local filesystem, creating parent directories as needed
+ ///
+ /// The relative path to the file
+ /// The stream containing the file content to write
+ /// Cancellation token to cancel the operation
public async Task WriteFileAsync(string path, Stream content, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath);
@@ -111,12 +147,25 @@ public async Task WriteFileAsync(string path, Stream content, CancellationToken
await content.CopyToAsync(fileStream, cancellationToken);
}
+ ///
+ /// Creates a directory on the local filesystem, including all parent directories if needed
+ ///
+ /// The relative path to the directory to create
+ /// Cancellation token to cancel the operation
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
Directory.CreateDirectory(fullPath);
await Task.CompletedTask;
}
+ ///
+ /// Deletes a file or directory from the local filesystem
+ ///
+ /// The relative path to the file or directory to delete
+ /// Cancellation token to cancel the operation
+ ///
+ /// If the path is a directory, it will be deleted recursively along with all its contents
+ ///
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
@@ -129,6 +178,13 @@ public async Task DeleteAsync(string path, CancellationToken cancellationToken =
await Task.CompletedTask;
}
+ ///
+ /// Moves or renames a file or directory on the local filesystem
+ ///
+ /// The relative path to the source file or directory
+ /// The relative path to the target location
+ /// Cancellation token to cancel the operation
+ /// Thrown when the source does not exist
public async Task MoveAsync(string sourcePath, string targetPath, CancellationToken cancellationToken = default) {
var sourceFullPath = GetFullPath(sourcePath);
var targetFullPath = GetFullPath(targetPath);
@@ -149,13 +205,24 @@ public async Task MoveAsync(string sourcePath, string targetPath, CancellationTo
await Task.CompletedTask;
}
+ ///
+ /// Checks whether a file or directory exists on the local filesystem
+ ///
+ /// The relative path to check
+ /// Cancellation token to cancel the operation
+ /// True if the file or directory exists, false otherwise
public async Task ExistsAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
return await Task.FromResult(Directory.Exists(fullPath) || File.Exists(fullPath));
}
+ ///
+ /// Gets storage space information for the drive containing the root path
+ ///
+ /// Cancellation token to cancel the operation
+ /// Storage information including total and used space
public async Task GetStorageInfoAsync(CancellationToken cancellationToken = default) {
- var driveInfo = new DriveInfo(Path.GetPathRoot(_rootPath)!);
+ var driveInfo = new DriveInfo(Path.GetPathRoot(RootPath)!);
return await Task.FromResult(new StorageInfo {
TotalSpace = driveInfo.TotalSize,
@@ -163,6 +230,13 @@ public async Task GetStorageInfoAsync(CancellationToken cancellatio
});
}
+ ///
+ /// Computes the SHA256 hash of a file on the local filesystem
+ ///
+ /// The relative path to the file
+ /// Cancellation token to cancel the operation
+ /// Base64-encoded SHA256 hash of the file contents
+ /// Thrown when the file does not exist
public async Task ComputeHashAsync(string path, CancellationToken cancellationToken = default) {
var fullPath = GetFullPath(path);
@@ -177,23 +251,28 @@ public async Task ComputeHashAsync(string path, CancellationToken cancel
return Convert.ToBase64String(hashBytes);
}
+ ///
+ /// Tests whether the local root directory is accessible
+ ///
+ /// Cancellation token to cancel the operation
+ /// True if the root directory exists and is accessible
public async Task TestConnectionAsync(CancellationToken cancellationToken = default) {
- return await Task.FromResult(Directory.Exists(_rootPath));
+ return await Task.FromResult(Directory.Exists(RootPath));
}
private string GetFullPath(string relativePath) {
if (string.IsNullOrEmpty(relativePath) || relativePath == "/") {
- return _rootPath;
+ return RootPath;
}
// Normalize path separators and remove leading slash
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar).TrimStart(Path.DirectorySeparatorChar);
- var fullPath = Path.Combine(_rootPath, relativePath);
+ var fullPath = Path.Combine(RootPath, relativePath);
// Ensure the path is within the root directory (security check)
var normalizedFullPath = Path.GetFullPath(fullPath);
- var normalizedRoot = Path.GetFullPath(_rootPath);
+ var normalizedRoot = Path.GetFullPath(RootPath);
if (!normalizedFullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) {
throw new UnauthorizedAccessException($"Path is outside root directory: {relativePath}");
@@ -203,7 +282,7 @@ private string GetFullPath(string relativePath) {
}
private string GetRelativePath(string fullPath) {
- var relativePath = Path.GetRelativePath(_rootPath, fullPath);
+ var relativePath = Path.GetRelativePath(RootPath, fullPath);
return relativePath.Replace(Path.DirectorySeparatorChar, '/');
}
diff --git a/src/SharpSync/Storage/SftpStorage.cs b/src/SharpSync/Storage/SftpStorage.cs
new file mode 100644
index 0000000..8a3d02b
--- /dev/null
+++ b/src/SharpSync/Storage/SftpStorage.cs
@@ -0,0 +1,944 @@
+using System.Security.Cryptography;
+using Oire.SharpSync.Core;
+using Renci.SshNet;
+using Renci.SshNet.Common;
+using Renci.SshNet.Sftp;
+
+namespace Oire.SharpSync.Storage;
+
+///
+/// SFTP storage implementation with support for password and key-based authentication
+/// Provides secure file synchronization over SSH File Transfer Protocol
+///
+public class SftpStorage: ISyncStorage, IDisposable {
+ private SftpClient? _client;
+ private readonly string _host;
+ private readonly int _port;
+ private readonly string _username;
+ private readonly string? _password;
+ private readonly string? _privateKeyPath;
+ private readonly string? _privateKeyPassphrase;
+
+ // Configuration
+ private readonly int _chunkSize;
+ private readonly int _maxRetries;
+ private readonly TimeSpan _retryDelay;
+ private readonly TimeSpan _connectionTimeout;
+
+ private readonly SemaphoreSlim _connectionSemaphore;
+ private bool _disposed;
+
+ // Path handling for chrooted servers
+ private string? _effectiveRoot; // Root as it should be used internally (no leading slash)
+ private bool _useRelativePaths; // True for chrooted servers that expect relative paths
+
+ ///
+ /// Gets the storage type (always returns )
+ ///
+ public StorageType StorageType => StorageType.Sftp;
+
+ ///
+ /// Gets the root path on the SFTP server
+ ///
+ public string RootPath { get; }
+
+ ///
+ /// Creates SFTP storage with password authentication
+ ///
+ /// SFTP server hostname
+ /// SFTP server port (default 22)
+ /// Username for authentication
+ /// Password for authentication
+ /// Root path on the SFTP server
+ /// Chunk size for large file uploads (default 10MB)
+ /// Maximum retry attempts (default 3)
+ /// Connection timeout in seconds (default 30)
+ public SftpStorage(
+ string host,
+ int port,
+ string username,
+ string password,
+ string rootPath = "",
+ int chunkSizeBytes = 10 * 1024 * 1024, // 10MB
+ int maxRetries = 3,
+ int connectionTimeoutSeconds = 30) {
+ if (string.IsNullOrWhiteSpace(host)) {
+ throw new ArgumentException("Host cannot be empty", nameof(host));
+ }
+
+ if (port <= 0 || port > 65535) {
+ throw new ArgumentException("Port must be between 1 and 65535", nameof(port));
+ }
+
+ if (string.IsNullOrWhiteSpace(username)) {
+ throw new ArgumentException("Username cannot be empty", nameof(username));
+ }
+
+ if (string.IsNullOrWhiteSpace(password)) {
+ throw new ArgumentException("Password cannot be empty", nameof(password));
+ }
+
+ _host = host;
+ _port = port;
+ _username = username;
+ _password = password;
+ RootPath = NormalizePath(rootPath);
+
+ _chunkSize = chunkSizeBytes;
+ _maxRetries = maxRetries;
+ _retryDelay = TimeSpan.FromSeconds(1);
+ _connectionTimeout = TimeSpan.FromSeconds(connectionTimeoutSeconds);
+
+ _connectionSemaphore = new SemaphoreSlim(1, 1);
+ }
+
+ ///
+ /// Creates SFTP storage with private key authentication
+ ///
+ /// SFTP server hostname
+ /// SFTP server port (default 22)
+ /// Username for authentication
+ /// Path to private key file
+ /// Passphrase for private key (if encrypted)
+ /// Root path on the SFTP server
+ /// Chunk size for large file uploads (default 10MB)
+ /// Maximum retry attempts (default 3)
+ /// Connection timeout in seconds (default 30)
+ public SftpStorage(
+ string host,
+ int port,
+ string username,
+ string privateKeyPath,
+ string? privateKeyPassphrase,
+ string rootPath = "",
+ int chunkSizeBytes = 10 * 1024 * 1024,
+ int maxRetries = 3,
+ int connectionTimeoutSeconds = 30) {
+ if (string.IsNullOrWhiteSpace(host)) {
+ throw new ArgumentException("Host cannot be empty", nameof(host));
+ }
+
+ if (port <= 0 || port > 65535) {
+ throw new ArgumentException("Port must be between 1 and 65535", nameof(port));
+ }
+
+ if (string.IsNullOrWhiteSpace(username)) {
+ throw new ArgumentException("Username cannot be empty", nameof(username));
+ }
+
+ if (string.IsNullOrWhiteSpace(privateKeyPath)) {
+ throw new ArgumentException("Private key path cannot be empty", nameof(privateKeyPath));
+ }
+
+ if (!File.Exists(privateKeyPath)) {
+ throw new FileNotFoundException($"Private key file not found: {privateKeyPath}");
+ }
+
+ _host = host;
+ _port = port;
+ _username = username;
+ _privateKeyPath = privateKeyPath;
+ _privateKeyPassphrase = privateKeyPassphrase;
+ RootPath = NormalizePath(rootPath);
+
+ _chunkSize = chunkSizeBytes;
+ _maxRetries = maxRetries;
+ _retryDelay = TimeSpan.FromSeconds(1);
+ _connectionTimeout = TimeSpan.FromSeconds(connectionTimeoutSeconds);
+
+ _connectionSemaphore = new SemaphoreSlim(1, 1);
+ }
+
+ ///
+ /// Event raised when upload/download progress changes
+ ///
+ public event EventHandler? ProgressChanged;
+
+ ///
+ /// Establishes connection to SFTP server
+ ///
+ private async Task EnsureConnectedAsync(CancellationToken cancellationToken = default) {
+ if (_client?.IsConnected == true) {
+ return;
+ }
+
+ await _connectionSemaphore.WaitAsync(cancellationToken);
+ try {
+ if (_client?.IsConnected == true) {
+ return;
+ }
+
+ // Dispose old client if exists
+ _client?.Dispose();
+
+ // Create connection info
+ ConnectionInfo connectionInfo;
+
+ if (!string.IsNullOrEmpty(_privateKeyPath)) {
+ // Key-based authentication
+ PrivateKeyFile keyFile = string.IsNullOrEmpty(_privateKeyPassphrase)
+ ? new PrivateKeyFile(_privateKeyPath)
+ : new PrivateKeyFile(_privateKeyPath, _privateKeyPassphrase);
+
+ connectionInfo = new ConnectionInfo(
+ _host,
+ _port,
+ _username,
+ new PrivateKeyAuthenticationMethod(_username, keyFile));
+ } else {
+ // Password authentication
+ connectionInfo = new ConnectionInfo(
+ _host,
+ _port,
+ _username,
+ new PasswordAuthenticationMethod(_username, _password));
+ }
+
+ connectionInfo.Timeout = _connectionTimeout;
+
+ // Create and connect client
+ _client = new SftpClient(connectionInfo);
+
+ await Task.Run(() => _client.Connect(), cancellationToken);
+
+ // Detect server path handling (chrooted vs normal) and set effective root
+ var normalizedRoot = string.IsNullOrEmpty(RootPath) ? "" : RootPath.TrimStart('/');
+
+ if (string.IsNullOrEmpty(normalizedRoot)) {
+ // No root path specified - detect whether server is chrooted or normal
+ // Chrooted servers require relative paths even with no configured root
+ try {
+ // Try probing with current directory (relative) vs root (absolute)
+ var canAccessRelative = SafeExists(".") || SafeExists("");
+ var canAccessAbsolute = SafeExists("/");
+
+ if (canAccessRelative && !canAccessAbsolute) {
+ // Can access relative but not absolute - chrooted server
+ _effectiveRoot = null;
+ _useRelativePaths = true;
+ } else if (canAccessAbsolute) {
+ // Can access absolute paths - normal server
+ _effectiveRoot = null;
+ _useRelativePaths = false;
+ } else {
+ // Conservative fallback: assume chrooted to avoid permission errors
+ _effectiveRoot = null;
+ _useRelativePaths = true;
+ }
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Permission error during probing - assume chrooted server
+ _effectiveRoot = null;
+ _useRelativePaths = true;
+ }
+ } else {
+ try {
+ // Try to detect which path form the server accepts
+ string? existingRoot = null;
+ var absoluteRoot = "/" + normalizedRoot;
+
+ // Try different path forms to detect chroot behavior
+ if (SafeExists(normalizedRoot)) {
+ // Relative path works - likely chrooted server
+ existingRoot = normalizedRoot;
+ _useRelativePaths = true;
+ } else if (SafeExists(absoluteRoot)) {
+ // Absolute path works - normal server
+ existingRoot = normalizedRoot;
+ _useRelativePaths = false;
+ } else {
+ // Path doesn't exist, try to create it
+ // Prefer relative creation for chrooted servers
+ var parts = normalizedRoot.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList();
+ var currentPath = "";
+ var createdWithRelative = false;
+
+ foreach (var part in parts) {
+ currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
+
+ if (!SafeExists(currentPath)) {
+ try {
+ // Try relative creation first
+ _client.CreateDirectory(currentPath);
+ createdWithRelative = true;
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Relative failed, try absolute
+ var absoluteCandidate = "/" + currentPath;
+ if (!SafeExists(absoluteCandidate)) {
+ try {
+ _client.CreateDirectory(absoluteCandidate);
+ createdWithRelative = false;
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Both failed - likely at chroot boundary, continue
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ existingRoot = normalizedRoot;
+ _useRelativePaths = createdWithRelative;
+ }
+
+ _effectiveRoot = existingRoot;
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Permission errors during detection - assume chrooted/relative behavior
+ _effectiveRoot = normalizedRoot;
+ _useRelativePaths = true;
+ }
+ }
+ } finally {
+ _connectionSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Tests the connection to the SFTP server
+ ///
+ /// Cancellation token to cancel the operation
+ /// True if connection is successful, false otherwise
+ public async Task TestConnectionAsync(CancellationToken cancellationToken = default) {
+ try {
+ await EnsureConnectedAsync(cancellationToken);
+ return _client?.IsConnected == true;
+ } catch {
+ return false;
+ }
+ }
+
+ ///
+ /// Lists all items (files and directories) in the specified path
+ ///
+ /// The relative path to list items from
+ /// Cancellation token to cancel the operation
+ /// A collection of sync items representing files and directories
+ /// Thrown when authentication fails
+ public async Task> ListItemsAsync(string path, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ return await ExecuteWithRetry(async () => {
+ var items = new List();
+
+ if (!SafeExists(fullPath)) {
+ return items;
+ }
+
+ var sftpFiles = await Task.Run(() => _client!.ListDirectory(fullPath), cancellationToken);
+
+ foreach (var file in sftpFiles) {
+ // Skip current and parent directory entries
+ if (file.Name == "." || file.Name == "..") {
+ continue;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ items.Add(new SyncItem {
+ Path = GetRelativePath(file.FullName),
+ IsDirectory = file.IsDirectory,
+ Size = file.Length,
+ LastModified = file.LastWriteTimeUtc,
+ Permissions = ConvertPermissionsToString(file),
+ MimeType = file.IsDirectory ? null : GetMimeType(file.Name)
+ });
+ }
+
+ return (IEnumerable)items;
+ }, cancellationToken);
+ }
+
+ ///
+ /// Gets metadata for a specific item (file or directory)
+ ///
+ /// The relative path to the item
+ /// Cancellation token to cancel the operation
+ /// The sync item if it exists, null otherwise
+ public async Task GetItemAsync(string path, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ return await ExecuteWithRetry(async () => {
+ if (!_client!.Exists(fullPath)) {
+ return null;
+ }
+
+ var file = await Task.Run(() => _client.Get(fullPath), cancellationToken);
+
+ return new SyncItem {
+ Path = path,
+ IsDirectory = file.IsDirectory,
+ Size = file.Length,
+ LastModified = file.LastWriteTimeUtc,
+ Permissions = ConvertPermissionsToString(file),
+ MimeType = file.IsDirectory ? null : GetMimeType(file.Name)
+ };
+ }, cancellationToken);
+ }
+
+ ///
+ /// Reads the contents of a file from the SFTP server
+ ///
+ /// The relative path to the file
+ /// Cancellation token to cancel the operation
+ /// A stream containing the file contents
+ /// Thrown when the file does not exist
+ /// Thrown when attempting to read a directory as a file
+ /// Thrown when authentication fails
+ ///
+ /// For files larger than the configured chunk size, progress events will be raised via
+ ///
+ public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ return await ExecuteWithRetry(async () => {
+ if (!_client!.Exists(fullPath)) {
+ throw new FileNotFoundException($"File not found: {path}");
+ }
+
+ var file = _client.Get(fullPath);
+ if (file.IsDirectory) {
+ throw new InvalidOperationException($"Cannot read directory as file: {path}");
+ }
+
+ var memoryStream = new MemoryStream();
+ var needsProgress = file.Length > _chunkSize;
+
+ if (needsProgress) {
+ // Download with progress reporting
+ ulong totalBytes = (ulong)file.Length;
+ ulong downloadedBytes = 0;
+
+ await Task.Run(() => {
+ _client.DownloadFile(fullPath, memoryStream, (uploaded) => {
+ downloadedBytes = uploaded;
+ RaiseProgressChanged(path, (long)downloadedBytes, (long)totalBytes, StorageOperation.Download);
+ });
+ }, cancellationToken);
+ } else {
+ // Download without progress
+ await Task.Run(() => _client.DownloadFile(fullPath, memoryStream), cancellationToken);
+ }
+
+ memoryStream.Position = 0;
+ return (Stream)memoryStream;
+ }, cancellationToken);
+ }
+
+ ///
+ /// Writes content to a file on the SFTP server, creating parent directories as needed
+ ///
+ /// The relative path to the file
+ /// The stream containing the file content to write
+ /// Cancellation token to cancel the operation
+ /// Thrown when authentication fails
+ ///
+ /// If the file already exists, it will be overwritten. For files larger than the configured chunk size,
+ /// progress events will be raised via
+ ///
+ public async Task WriteFileAsync(string path, Stream content, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ // Ensure parent directories exist
+ var directory = GetParentDirectory(fullPath);
+ if (!string.IsNullOrEmpty(directory)) {
+ await CreateDirectoryAsync(GetRelativePath(directory), cancellationToken);
+ }
+
+ await ExecuteWithRetry(async () => {
+ var needsProgress = content.CanSeek && content.Length > _chunkSize;
+
+ if (needsProgress) {
+ // Upload with progress reporting
+ ulong totalBytes = (ulong)content.Length;
+ ulong uploadedBytes = 0;
+
+ await Task.Run(() => {
+ _client!.UploadFile(content, fullPath, true, (uploaded) => {
+ uploadedBytes = uploaded;
+ RaiseProgressChanged(path, (long)uploadedBytes, (long)totalBytes, StorageOperation.Upload);
+ });
+ }, cancellationToken);
+ } else {
+ // Upload without progress
+ await Task.Run(() => _client!.UploadFile(content, fullPath, true), cancellationToken);
+ }
+
+ return true;
+ }, cancellationToken);
+ }
+
+ ///
+ /// Creates a directory on the SFTP server, including all parent directories if needed
+ ///
+ /// The relative path to the directory to create
+ /// Cancellation token to cancel the operation
+ /// Thrown when authentication fails
+ ///
+ /// If the directory already exists, this method completes successfully without error
+ ///
+ public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ // Don't attempt to create root or current directory - treat them as already existing
+ if (fullPath == "." || fullPath == "/" || string.IsNullOrEmpty(fullPath)) {
+ return;
+ }
+
+ await ExecuteWithRetry(async () => {
+ if (SafeExists(fullPath)) {
+ return true; // Directory already exists
+ }
+
+ // Create parent directories recursively if needed
+ var parts = fullPath.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList();
+ var currentPath = _useRelativePaths ? "" : (fullPath.StartsWith('/') ? "/" : "");
+
+ foreach (var part in parts) {
+ if (_useRelativePaths) {
+ currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
+ } else {
+ currentPath = string.IsNullOrEmpty(currentPath) || currentPath == "/"
+ ? $"/{part}"
+ : $"{currentPath}/{part}";
+ }
+
+ if (!SafeExists(currentPath)) {
+ try {
+ await Task.Run(() => _client!.CreateDirectory(currentPath), cancellationToken);
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Try alternate path form (relative vs absolute)
+ var alternatePath = currentPath.StartsWith('/') ? currentPath.TrimStart('/') : "/" + currentPath;
+ if (!SafeExists(alternatePath)) {
+ try {
+ await Task.Run(() => _client!.CreateDirectory(alternatePath), cancellationToken);
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Both forms failed - check if either now exists, otherwise rethrow
+ if (!SafeExists(currentPath) && !SafeExists(alternatePath)) {
+ throw;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return true;
+ }, cancellationToken);
+ }
+
+ ///
+ /// Deletes a file or directory from the SFTP server
+ ///
+ /// The relative path to the file or directory to delete
+ /// Cancellation token to cancel the operation
+ /// Thrown when authentication fails
+ ///
+ /// If the path is a directory, it will be deleted recursively along with all its contents.
+ /// If the item does not exist, this method completes successfully without error
+ ///
+ public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ await ExecuteWithRetry(async () => {
+ if (!_client!.Exists(fullPath)) {
+ return true; // Already deleted
+ }
+
+ var file = _client.Get(fullPath);
+
+ if (file.IsDirectory) {
+ await Task.Run(() => DeleteDirectoryRecursive(fullPath, cancellationToken), cancellationToken);
+ } else {
+ await Task.Run(() => _client.DeleteFile(fullPath), cancellationToken);
+ }
+
+ return true;
+ }, cancellationToken);
+ }
+
+ ///
+ /// Recursively deletes a directory and all its contents
+ ///
+ private void DeleteDirectoryRecursive(string path, CancellationToken cancellationToken) {
+ foreach (var file in _client!.ListDirectory(path)) {
+ if (file.Name == "." || file.Name == "..") {
+ continue;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (file.IsDirectory) {
+ DeleteDirectoryRecursive(file.FullName, cancellationToken);
+ } else {
+ _client.DeleteFile(file.FullName);
+ }
+ }
+
+ _client.DeleteDirectory(path);
+ }
+
+ ///
+ /// Moves or renames a file or directory on the SFTP server
+ ///
+ /// The relative path to the source file or directory
+ /// The relative path to the target location
+ /// Cancellation token to cancel the operation
+ /// Thrown when the source does not exist
+ /// Thrown when authentication fails
+ ///
+ /// Parent directories of the target path will be created if they don't exist
+ ///
+ public async Task MoveAsync(string sourcePath, string targetPath, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var sourceFullPath = GetFullPath(sourcePath);
+ var targetFullPath = GetFullPath(targetPath);
+
+ // Ensure target parent directory exists
+ // Use the original targetPath (normalized relative form) to compute parent directory
+ // This avoids mixing full/absolute path forms that can confuse creation on chrooted servers
+ var normalizedTargetPath = NormalizePath(targetPath);
+ var targetParentRelative = GetParentDirectory(normalizedTargetPath);
+ if (!string.IsNullOrEmpty(targetParentRelative)) {
+ await CreateDirectoryAsync(targetParentRelative, cancellationToken);
+ }
+
+ await ExecuteWithRetry(async () => {
+ if (!_client!.Exists(sourceFullPath)) {
+ throw new FileNotFoundException($"Source not found: {sourcePath}");
+ }
+
+ // SFTP doesn't have a native rename across directories, so we use RenameFile
+ // which works for both files and directories
+ await Task.Run(() => _client.RenameFile(sourceFullPath, targetFullPath), cancellationToken);
+
+ return true;
+ }, cancellationToken);
+ }
+
+ ///
+ /// Checks whether a file or directory exists on the SFTP server
+ ///
+ /// The relative path to check
+ /// Cancellation token to cancel the operation
+ /// True if the file or directory exists, false otherwise
+ public async Task ExistsAsync(string path, CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ var fullPath = GetFullPath(path);
+
+ return await ExecuteWithRetry(async () => {
+ return await Task.Run(() => _client!.Exists(fullPath), cancellationToken);
+ }, cancellationToken);
+ }
+
+ ///
+ /// Gets storage space information for the SFTP server
+ ///
+ /// Cancellation token to cancel the operation
+ /// Storage information (may return -1 for unknown values as SFTP protocol has limited support)
+ ///
+ /// SFTP protocol does not have standardized disk space reporting. This method returns
+ /// best-effort values which may be -1 if the server doesn't support disk space queries
+ ///
+ public async Task GetStorageInfoAsync(CancellationToken cancellationToken = default) {
+ await EnsureConnectedAsync(cancellationToken);
+
+ return await ExecuteWithRetry(async () => {
+ // Try to get disk space using statvfs
+ try {
+ var statVfs = await Task.Run(() => _client!.GetStatus(RootPath.Length != 0 ? RootPath : "/"), cancellationToken);
+
+ // SFTP doesn't have a standard way to get disk space
+ // This is a best-effort approach using SSH commands if available
+ // For now, return unknown values
+ return new StorageInfo {
+ TotalSpace = -1,
+ UsedSpace = -1
+ };
+ } catch {
+ // If we can't get storage info, return unknown values
+ return new StorageInfo {
+ TotalSpace = -1,
+ UsedSpace = -1
+ };
+ }
+ }, cancellationToken);
+ }
+
+ ///
+ /// Computes the SHA256 hash of a file on the SFTP server
+ ///
+ /// The relative path to the file
+ /// Cancellation token to cancel the operation
+ /// Base64-encoded SHA256 hash of the file contents
+ /// Thrown when the file does not exist
+ ///
+ /// Since SFTP protocol doesn't provide native hash computation, this method downloads
+ /// the file and computes the hash locally. For large files, consider the performance implications
+ ///
+ public async Task ComputeHashAsync(string path, CancellationToken cancellationToken = default) {
+ // SFTP doesn't have native hash support, so we download and hash
+ using var stream = await ReadFileAsync(path, cancellationToken);
+ using var sha256 = SHA256.Create();
+
+ var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
+ return Convert.ToBase64String(hashBytes);
+ }
+
+ #region Helper Methods
+
+ ///
+ /// Normalizes a path for SFTP (uses forward slashes)
+ ///
+ private static string NormalizePath(string path) {
+ if (string.IsNullOrWhiteSpace(path)) {
+ return "";
+ }
+
+ // Convert backslashes to forward slashes
+ path = path.Replace('\\', '/');
+
+ // Remove trailing slashes
+ path = path.TrimEnd('/');
+
+ // Ensure path doesn't start with slash (unless it's root)
+ if (path == "/") {
+ return "/";
+ }
+
+ return path.TrimStart('/');
+ }
+
+ ///
+ /// Safely checks if a path exists, handling permission denied exceptions for chrooted servers
+ ///
+ private bool SafeExists(string path) {
+ try {
+ return _client!.Exists(path);
+ } catch (Renci.SshNet.Common.SftpPermissionDeniedException) {
+ // Try alternate form (relative vs absolute)
+ var alternatePath = path.StartsWith('/') ? path.TrimStart('/') : "/" + path;
+ try {
+ return _client!.Exists(alternatePath);
+ } catch {
+ // Both forms failed, treat as non-existent
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Gets the full path on the SFTP server, respecting chroot behavior
+ ///
+ private string GetFullPath(string relativePath) {
+ if (string.IsNullOrEmpty(relativePath) || relativePath == "/") {
+ if (string.IsNullOrEmpty(_effectiveRoot)) {
+ return _useRelativePaths ? "." : "/";
+ }
+ return _useRelativePaths ? _effectiveRoot! : $"/{_effectiveRoot}";
+ }
+
+ relativePath = NormalizePath(relativePath);
+
+ if (string.IsNullOrEmpty(_effectiveRoot)) {
+ return _useRelativePaths ? relativePath : $"/{relativePath}";
+ }
+
+ return _useRelativePaths
+ ? $"{_effectiveRoot}/{relativePath}"
+ : $"/{_effectiveRoot}/{relativePath}";
+ }
+
+ ///
+ /// Gets the relative path from a full SFTP path
+ ///
+ private string GetRelativePath(string fullPath) {
+ var prefix = string.IsNullOrEmpty(RootPath) || RootPath == "/"
+ ? "/"
+ : $"/{RootPath}/";
+
+ if (fullPath.StartsWith(prefix)) {
+ var relativePath = fullPath.Substring(prefix.Length);
+ return string.IsNullOrEmpty(relativePath) ? "/" : relativePath;
+ }
+
+ return fullPath;
+ }
+
+ ///
+ /// Gets the parent directory of a path
+ ///
+ private static string GetParentDirectory(string path) {
+ if (string.IsNullOrEmpty(path)) {
+ return string.Empty;
+ }
+
+ var lastSlash = path.LastIndexOf('/');
+ // If there is no parent (path has no slash or the only slash is the first character),
+ // return empty string so callers skip creating directories for root
+ if (lastSlash <= 0) {
+ return string.Empty;
+ }
+
+ return path.Substring(0, lastSlash);
+ }
+
+ ///
+ /// Converts SFTP file permissions to a string representation
+ ///
+ private static string ConvertPermissionsToString(ISftpFile file) {
+ if (file.Attributes == null) {
+ return string.Empty;
+ }
+
+ var result = new char[10];
+
+ // File type
+ if (file.IsDirectory) {
+ result[0] = 'd';
+ } else if (file.Attributes.IsSymbolicLink) {
+ result[0] = 'l';
+ } else {
+ result[0] = '-';
+ }
+
+ // Owner permissions
+ result[1] = file.Attributes.OwnerCanRead ? 'r' : '-';
+ result[2] = file.Attributes.OwnerCanWrite ? 'w' : '-';
+ result[3] = file.Attributes.OwnerCanExecute ? 'x' : '-';
+
+ // Group permissions
+ result[4] = file.Attributes.GroupCanRead ? 'r' : '-';
+ result[5] = file.Attributes.GroupCanWrite ? 'w' : '-';
+ result[6] = file.Attributes.GroupCanExecute ? 'x' : '-';
+
+ // Others permissions
+ result[7] = file.Attributes.OthersCanRead ? 'r' : '-';
+ result[8] = file.Attributes.OthersCanWrite ? 'w' : '-';
+ result[9] = file.Attributes.OthersCanExecute ? 'x' : '-';
+
+ return new string(result);
+ }
+
+ ///
+ /// Gets MIME type based on file extension
+ ///
+ private static string GetMimeType(string fileName) {
+ var extension = Path.GetExtension(fileName).ToLowerInvariant();
+ return extension switch {
+ ".txt" => "text/plain",
+ ".pdf" => "application/pdf",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".png" => "image/png",
+ ".gif" => "image/gif",
+ ".zip" => "application/zip",
+ ".json" => "application/json",
+ ".xml" => "application/xml",
+ ".html" or ".htm" => "text/html",
+ ".css" => "text/css",
+ ".js" => "application/javascript",
+ ".mp3" => "audio/mpeg",
+ ".mp4" => "video/mp4",
+ ".doc" => "application/msword",
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ".xls" => "application/vnd.ms-excel",
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ _ => "application/octet-stream"
+ };
+ }
+
+ ///
+ /// Executes an operation with retry logic
+ ///
+ private async Task ExecuteWithRetry(Func> operation, CancellationToken cancellationToken) {
+ Exception? lastException = null;
+
+ for (int attempt = 0; attempt <= _maxRetries; attempt++) {
+ try {
+ cancellationToken.ThrowIfCancellationRequested();
+ return await operation();
+ } catch (Exception ex) when (attempt < _maxRetries && IsRetriableException(ex)) {
+ lastException = ex;
+
+ // Reconnect if connection was lost
+ if (ex is SshConnectionException || ex is SshOperationTimeoutException) {
+ try {
+ _client?.Disconnect();
+ _client?.Dispose();
+ _client = null;
+ await EnsureConnectedAsync(cancellationToken);
+ } catch {
+ // Ignore reconnection errors, will retry
+ }
+ }
+
+ await Task.Delay(_retryDelay * (attempt + 1), cancellationToken);
+ }
+ }
+
+ throw lastException ?? new InvalidOperationException("Operation failed");
+ }
+
+ ///
+ /// Determines if an exception is retriable
+ ///
+ private static bool IsRetriableException(Exception ex) {
+ return ex is SshConnectionException ||
+ ex is SshOperationTimeoutException ||
+ ex is SftpPermissionDeniedException == false; // Don't retry permission errors
+ }
+
+ ///
+ /// Raises progress changed event
+ ///
+ private void RaiseProgressChanged(string path, long completed, long total, StorageOperation operation) {
+ ProgressChanged?.Invoke(this, new StorageProgressEventArgs {
+ Path = path,
+ BytesTransferred = completed,
+ TotalBytes = total,
+ Operation = operation,
+ PercentComplete = total > 0 ? (int)((completed * 100L) / total) : 0
+ });
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ ///
+ /// Releases all resources used by the SFTP storage instance
+ ///
+ ///
+ /// Disconnects from the SFTP server and disposes of the underlying SSH client and connection semaphore.
+ /// This method can be called multiple times safely
+ ///
+ public void Dispose() {
+ if (!_disposed) {
+ try {
+ _client?.Disconnect();
+ } catch {
+ // Ignore disconnection errors during disposal
+ }
+
+ _client?.Dispose();
+ _connectionSemaphore?.Dispose();
+ _disposed = true;
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ #endregion
+}
diff --git a/src/SharpSync/Storage/WebDavStorage.cs b/src/SharpSync/Storage/WebDavStorage.cs
index 104307b..285369e 100644
--- a/src/SharpSync/Storage/WebDavStorage.cs
+++ b/src/SharpSync/Storage/WebDavStorage.cs
@@ -16,7 +16,6 @@ namespace Oire.SharpSync.Storage;
public class WebDavStorage: ISyncStorage, IDisposable {
private WebDavClient _client;
private readonly string _baseUrl;
- private readonly string _rootPath;
private readonly IOAuth2Provider? _oauth2Provider;
private readonly OAuth2Config? _oauth2Config;
private OAuth2Result? _oauth2Result;
@@ -34,8 +33,15 @@ public class WebDavStorage: ISyncStorage, IDisposable {
private bool _disposed;
+ ///
+ /// Gets the storage type (always returns )
+ ///
public StorageType StorageType => StorageType.WebDav;
- public string RootPath => _rootPath;
+
+ ///
+ /// Gets the root path within the WebDAV share
+ ///
+ public string RootPath { get; }
///
/// Creates WebDAV storage with OAuth2 support
@@ -60,7 +66,7 @@ public WebDavStorage(
}
_baseUrl = baseUrl.TrimEnd('/');
- _rootPath = rootPath.Trim('/');
+ RootPath = rootPath.Trim('/');
_oauth2Provider = oauth2Provider;
_oauth2Config = oauth2Config;
_authSemaphore = new SemaphoreSlim(1, 1);
@@ -83,6 +89,13 @@ public WebDavStorage(
///
/// Creates WebDAV storage with basic authentication
///
+ /// Base WebDAV URL (e.g., https://cloud.example.com/remote.php/dav/files/username/)
+ /// Username for basic authentication
+ /// Password for basic authentication
+ /// Root path within the WebDAV share
+ /// Chunk size for large file uploads (default 10MB)
+ /// Maximum retry attempts (default 3)
+ /// Request timeout in seconds (default 300)
public WebDavStorage(
string baseUrl,
string username,
@@ -182,6 +195,11 @@ private void UpdateClientAuth() {
}
}
+ ///
+ /// Tests whether the WebDAV server is accessible and authentication is working
+ ///
+ /// Cancellation token to cancel the operation
+ /// True if the connection is successful, false otherwise
public async Task TestConnectionAsync(CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
return false;
@@ -195,6 +213,12 @@ public async Task TestConnectionAsync(CancellationToken cancellationToken
}, cancellationToken);
}
+ ///
+ /// Lists all items (files and directories) in the specified path
+ ///
+ /// The relative path to list items from
+ /// Cancellation token to cancel the operation
+ /// A collection of sync items representing files and directories
public async Task> ListItemsAsync(string path, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");
@@ -229,6 +253,12 @@ public async Task> ListItemsAsync(string path, Cancellatio
}, cancellationToken);
}
+ ///
+ /// Gets metadata for a specific item (file or directory)
+ ///
+ /// The relative path to the item
+ /// Cancellation token to cancel the operation
+ /// The sync item if it exists, null otherwise
public async Task GetItemAsync(string path, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
return null;
@@ -261,6 +291,14 @@ public async Task> ListItemsAsync(string path, Cancellatio
}, cancellationToken);
}
+ ///
+ /// Reads the contents of a file from the WebDAV server
+ ///
+ /// The relative path to the file
+ /// Cancellation token to cancel the operation
+ /// A stream containing the file contents
+ /// Thrown when the file does not exist
+ /// Thrown when authentication fails
public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");
@@ -294,6 +332,16 @@ public async Task ReadFileAsync(string path, CancellationToken cancellat
}, cancellationToken);
}
+ ///
+ /// Writes content to a file on the WebDAV server, creating parent directories as needed
+ ///
+ /// The relative path to the file
+ /// The stream containing the file content to write
+ /// Cancellation token to cancel the operation
+ ///
+ /// For large files (larger than chunk size), uses platform-specific chunked uploads if supported (Nextcloud chunking v2, OCIS TUS).
+ /// Progress events are raised during large file uploads.
+ ///
public async Task WriteFileAsync(string path, Stream content, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");
@@ -465,6 +513,11 @@ await ExecuteWithRetry(async () => {
}, cancellationToken);
}
+ ///
+ /// Creates a directory on the WebDAV server, including all parent directories if needed
+ ///
+ /// The relative path to the directory to create
+ /// Cancellation token to cancel the operation
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");
@@ -492,6 +545,14 @@ await ExecuteWithRetry(async () => {
}, cancellationToken);
}
+ ///
+ /// Deletes a file or directory from the WebDAV server
+ ///
+ /// The relative path to the file or directory to delete
+ /// Cancellation token to cancel the operation
+ ///
+ /// If the path is a directory, it will be deleted recursively along with all its contents
+ ///
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");
@@ -510,6 +571,12 @@ await ExecuteWithRetry(async () => {
}, cancellationToken);
}
+ ///
+ /// Moves or renames a file or directory on the WebDAV server
+ ///
+ /// The relative path to the source file or directory
+ /// The relative path to the target location
+ /// Cancellation token to cancel the operation
public async Task MoveAsync(string sourcePath, string targetPath, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
throw new UnauthorizedAccessException("Authentication failed");
@@ -536,6 +603,12 @@ await ExecuteWithRetry(async () => {
}, cancellationToken);
}
+ ///
+ /// Checks whether a file or directory exists on the WebDAV server
+ ///
+ /// The relative path to check
+ /// Cancellation token to cancel the operation
+ /// True if the file or directory exists, false otherwise
public async Task ExistsAsync(string path, CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
return false;
@@ -552,6 +625,11 @@ public async Task ExistsAsync(string path, CancellationToken cancellationT
}, cancellationToken);
}
+ ///
+ /// Gets storage space information from the WebDAV server
+ ///
+ /// Cancellation token to cancel the operation
+ /// Storage information including total and used space, or -1 if not supported
public async Task GetStorageInfoAsync(CancellationToken cancellationToken = default) {
if (!await EnsureAuthenticated(cancellationToken))
return new StorageInfo { TotalSpace = -1, UsedSpace = -1 };
@@ -586,6 +664,16 @@ public async Task GetStorageInfoAsync(CancellationToken cancellatio
}, cancellationToken);
}
+ ///
+ /// Computes a hash for a file on the WebDAV server
+ ///
+ /// The relative path to the file
+ /// Cancellation token to cancel the operation
+ /// Hash of the file contents (uses ETag if available, server checksum for Nextcloud/OCIS, or SHA256 as fallback)
+ ///
+ /// This method optimizes hash computation by using ETags or server-side checksums when available.
+ /// Falls back to downloading and computing SHA256 hash for servers that don't support these features.
+ ///
public async Task ComputeHashAsync(string path, CancellationToken cancellationToken = default) {
// Use ETag if available for performance (avoids downloading the file)
var item = await GetItemAsync(path, cancellationToken);
@@ -734,18 +822,18 @@ private async Task DetectServerCapabilitiesAsync(Cancellatio
private string GetFullPath(string relativePath) {
if (string.IsNullOrEmpty(relativePath) || relativePath == "/")
- return string.IsNullOrEmpty(_rootPath) ? _baseUrl : $"{_baseUrl}/{_rootPath}";
+ return string.IsNullOrEmpty(RootPath) ? _baseUrl : $"{_baseUrl}/{RootPath}";
relativePath = relativePath.Trim('/');
- if (string.IsNullOrEmpty(_rootPath))
+ if (string.IsNullOrEmpty(RootPath))
return $"{_baseUrl}/{relativePath}";
else
- return $"{_baseUrl}/{_rootPath}/{relativePath}";
+ return $"{_baseUrl}/{RootPath}/{relativePath}";
}
private string GetRelativePath(string fullUrl) {
- var prefix = string.IsNullOrEmpty(_rootPath) ? _baseUrl : $"{_baseUrl}/{_rootPath}";
+ var prefix = string.IsNullOrEmpty(RootPath) ? _baseUrl : $"{_baseUrl}/{RootPath}";
if (fullUrl.StartsWith(prefix)) {
var relativePath = fullUrl.Substring(prefix.Length).Trim('/');
@@ -799,6 +887,13 @@ private void RaiseProgressChanged(string path, long completed, long total, Stora
#region IDisposable
+ ///
+ /// Releases all resources used by the WebDAV storage
+ ///
+ ///
+ /// Disposes of the WebDAV client and authentication semaphores.
+ /// This method can be called multiple times safely. After disposal, the storage instance cannot be reused.
+ ///
public void Dispose() {
if (!_disposed) {
_client?.Dispose();
diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs
index 43244d5..3133841 100644
--- a/src/SharpSync/Sync/SyncEngine.cs
+++ b/src/SharpSync/Sync/SyncEngine.cs
@@ -1018,6 +1018,13 @@ public async Task ResetSyncStateAsync(CancellationToken cancellationToken = defa
await _database.ClearAsync(cancellationToken);
}
+ ///
+ /// Releases all resources used by the sync engine
+ ///
+ ///
+ /// Cancels any ongoing synchronization operation and disposes of the synchronization semaphore.
+ /// This method can be called multiple times safely. After disposal, the sync engine cannot be reused.
+ ///
public void Dispose() {
if (!_disposed) {
_currentSyncCts?.Cancel();
diff --git a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs
new file mode 100644
index 0000000..e5b87cf
--- /dev/null
+++ b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs
@@ -0,0 +1,655 @@
+namespace Oire.SharpSync.Tests.Storage;
+
+///
+/// Unit and integration tests for SftpStorage
+/// NOTE: Integration tests require a real SFTP server. Set up environment variables:
+/// - SFTP_TEST_HOST: SFTP server hostname
+/// - SFTP_TEST_PORT: SFTP server port (default 22)
+/// - SFTP_TEST_USER: SFTP username
+/// - SFTP_TEST_PASS: SFTP password (for password auth)
+/// - SFTP_TEST_KEY: Path to private key file (for key auth)
+/// - SFTP_TEST_ROOT: Root path on server (default: /tmp/sharpsync-tests)
+///
+public class SftpStorageTests: IDisposable {
+ private readonly string? _testHost;
+ private readonly int _testPort;
+ private readonly string? _testUser;
+ private readonly string? _testPass;
+ private readonly string? _testKey;
+ private readonly string _testRoot;
+ private readonly bool _integrationTestsEnabled;
+ private SftpStorage? _storage;
+
+ public SftpStorageTests() {
+ // Read environment variables for integration tests
+ _testHost = Environment.GetEnvironmentVariable("SFTP_TEST_HOST");
+ _testUser = Environment.GetEnvironmentVariable("SFTP_TEST_USER");
+ _testPass = Environment.GetEnvironmentVariable("SFTP_TEST_PASS");
+ _testKey = Environment.GetEnvironmentVariable("SFTP_TEST_KEY");
+ _testRoot = Environment.GetEnvironmentVariable("SFTP_TEST_ROOT") ?? "/tmp/sharpsync-tests";
+
+ var portStr = Environment.GetEnvironmentVariable("SFTP_TEST_PORT");
+ _testPort = int.TryParse(portStr, out var port) ? port : 22;
+
+ _integrationTestsEnabled = !string.IsNullOrEmpty(_testHost) &&
+ !string.IsNullOrEmpty(_testUser) &&
+ (!string.IsNullOrEmpty(_testPass) || !string.IsNullOrEmpty(_testKey));
+ }
+
+ public void Dispose() {
+ _storage?.Dispose();
+ }
+
+ #region Unit Tests (No Server Required)
+
+ [Fact]
+ public void Constructor_PasswordAuth_ValidParameters_CreatesStorage() {
+ // Act
+ using var storage = new SftpStorage("example.com", 22, "user", "password");
+
+ // Assert
+ Assert.Equal(StorageType.Sftp, storage.StorageType);
+ }
+
+ [Fact]
+ public void Constructor_PasswordAuth_EmptyHost_ThrowsException() {
+ // Act & Assert
+ Assert.Throws(() => new SftpStorage("", 22, "user", "password"));
+ }
+
+ [Fact]
+ public void Constructor_PasswordAuth_InvalidPort_ThrowsException() {
+ // Act & Assert
+ Assert.Throws(() => new SftpStorage("example.com", 0, "user", "password"));
+ Assert.Throws(() => new SftpStorage("example.com", 70000, "user", "password"));
+ }
+
+ [Fact]
+ public void Constructor_PasswordAuth_EmptyUsername_ThrowsException() {
+ // Act & Assert
+ Assert.Throws(() => new SftpStorage("example.com", 22, "", "password"));
+ }
+
+ [Fact]
+ public void Constructor_PasswordAuth_EmptyPassword_ThrowsException() {
+ // Act & Assert
+ Assert.Throws(() => new SftpStorage("example.com", 22, "user", ""));
+ }
+
+ [Fact]
+ public void Constructor_KeyAuth_NonexistentKeyFile_ThrowsException() {
+ // Arrange
+ var nonexistentKey = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".key");
+
+ // Act & Assert
+ Assert.Throws(() =>
+ new SftpStorage("example.com", 22, "user", privateKeyPath: nonexistentKey, privateKeyPassphrase: null));
+ }
+
+ [Fact]
+ public void RootPath_Property_ReturnsCorrectPath() {
+ // Arrange
+ var rootPath = "test/path";
+ using var storage = new SftpStorage("example.com", 22, "user", password: "password", rootPath: rootPath);
+
+ // Assert
+ Assert.Equal(rootPath, storage.RootPath);
+ }
+
+ [Fact]
+ public void StorageType_Property_ReturnsSftp() {
+ // Arrange
+ using var storage = new SftpStorage("example.com", 22, "user", "password");
+
+ // Assert
+ Assert.Equal(StorageType.Sftp, storage.StorageType);
+ }
+
+ #endregion
+
+ #region Integration Tests (Require SFTP Server)
+
+ private void SkipIfIntegrationTestsDisabled() {
+ if (!_integrationTestsEnabled) {
+ throw new SkipException("Integration tests disabled. Set SFTP_TEST_HOST, SFTP_TEST_USER, and SFTP_TEST_PASS environment variables.");
+ }
+ }
+
+ private SftpStorage CreateStorage() {
+ SkipIfIntegrationTestsDisabled();
+
+ if (!string.IsNullOrEmpty(_testKey)) {
+ // Key-based authentication
+ return new SftpStorage(_testHost!, _testPort, _testUser!, privateKeyPath: _testKey, privateKeyPassphrase: null, rootPath: $"{_testRoot}/{Guid.NewGuid()}");
+ } else {
+ // Password authentication
+ return new SftpStorage(_testHost!, _testPort, _testUser!, password: _testPass!, rootPath: $"{_testRoot}/{Guid.NewGuid()}");
+ }
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_ValidCredentials_ReturnsTrue() {
+ SkipIfIntegrationTestsDisabled();
+
+ // Arrange
+ using var storage = CreateStorage();
+
+ // Act
+ var result = await storage.TestConnectionAsync();
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_InvalidCredentials_ReturnsFalse() {
+ SkipIfIntegrationTestsDisabled();
+
+ // Arrange
+ using var storage = new SftpStorage(_testHost!, _testPort, _testUser!, "wrong_password");
+
+ // Act
+ var result = await storage.TestConnectionAsync();
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CreateDirectoryAsync_CreatesDirectory() {
+ // Arrange
+ _storage = CreateStorage();
+ var dirPath = "test/subdir";
+
+ // Act
+ await _storage.CreateDirectoryAsync(dirPath);
+ var exists = await _storage.ExistsAsync(dirPath);
+
+ // Assert
+ Assert.True(exists);
+ }
+
+ [Fact]
+ public async Task WriteFileAsync_CreatesFile() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "test.txt";
+ var content = "Hello, SFTP World!";
+
+ // Act
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(filePath);
+ Assert.True(exists);
+ }
+
+ [Fact]
+ public async Task ReadFileAsync_ReturnsFileContent() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "test_read.txt";
+ var content = "Hello, SFTP World!";
+
+ using var writeStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(filePath, writeStream);
+
+ // Act
+ using var readStream = await _storage.ReadFileAsync(filePath);
+ using var reader = new StreamReader(readStream);
+ var result = await reader.ReadToEndAsync();
+
+ // Assert
+ Assert.Equal(content, result);
+ }
+
+ [Fact]
+ public async Task ReadFileAsync_NonexistentFile_ThrowsException() {
+ // Arrange
+ _storage = CreateStorage();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _storage.ReadFileAsync("nonexistent.txt"));
+ }
+
+ [Fact]
+ public async Task ExistsAsync_ExistingFile_ReturnsTrue() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "exists_test.txt";
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("test"));
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Act
+ var result = await _storage.ExistsAsync(filePath);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task ExistsAsync_NonexistentFile_ReturnsFalse() {
+ // Arrange
+ _storage = CreateStorage();
+
+ // Act
+ var result = await _storage.ExistsAsync("nonexistent.txt");
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_ExistingFile_DeletesFile() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "delete_test.txt";
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("test"));
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Act
+ await _storage.DeleteAsync(filePath);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(filePath);
+ Assert.False(exists);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_Directory_DeletesDirectory() {
+ // Arrange
+ _storage = CreateStorage();
+ var dirPath = "delete_dir_test";
+ await _storage.CreateDirectoryAsync(dirPath);
+
+ // Act
+ await _storage.DeleteAsync(dirPath);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(dirPath);
+ Assert.False(exists);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_DirectoryWithContents_DeletesRecursively() {
+ // Arrange
+ _storage = CreateStorage();
+ var dirPath = "delete_recursive";
+ var filePath = "delete_recursive/file.txt";
+
+ await _storage.CreateDirectoryAsync(dirPath);
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("test"));
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Act
+ await _storage.DeleteAsync(dirPath);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(dirPath);
+ Assert.False(exists);
+ }
+
+ [Fact]
+ public async Task MoveAsync_MovesFile() {
+ // Arrange
+ _storage = CreateStorage();
+ var sourcePath = "source.txt";
+ var targetPath = "target.txt";
+ var content = "test content";
+
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(sourcePath, stream);
+
+ // Act
+ await _storage.MoveAsync(sourcePath, targetPath);
+
+ // Assert
+ var sourceExists = await _storage.ExistsAsync(sourcePath);
+ var targetExists = await _storage.ExistsAsync(targetPath);
+
+ Assert.False(sourceExists);
+ Assert.True(targetExists);
+
+ // Verify content
+ using var readStream = await _storage.ReadFileAsync(targetPath);
+ using var reader = new StreamReader(readStream);
+ var result = await reader.ReadToEndAsync();
+ Assert.Equal(content, result);
+ }
+
+ [Fact]
+ public async Task MoveAsync_ToSubdirectory_MovesCorrectly() {
+ // Arrange
+ _storage = CreateStorage();
+ var sourcePath = "move_source.txt";
+ var targetPath = "subdir/move_target.txt";
+ var content = "test content";
+
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(sourcePath, stream);
+
+ // Act
+ await _storage.MoveAsync(sourcePath, targetPath);
+
+ // Assert
+ var sourceExists = await _storage.ExistsAsync(sourcePath);
+ var targetExists = await _storage.ExistsAsync(targetPath);
+
+ Assert.False(sourceExists);
+ Assert.True(targetExists);
+ }
+
+ [Fact]
+ public async Task ListItemsAsync_ReturnsItems() {
+ // Arrange
+ _storage = CreateStorage();
+
+ // Create test files and directories
+ using var stream1 = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("content1"));
+ await _storage.WriteFileAsync("file1.txt", stream1);
+
+ await _storage.CreateDirectoryAsync("subdir");
+
+ using var stream2 = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("content2"));
+ await _storage.WriteFileAsync("subdir/file2.txt", stream2);
+
+ // Act
+ var items = await _storage.ListItemsAsync("");
+
+ // Assert
+ var itemsList = items.ToList();
+ Assert.True(itemsList.Count >= 2);
+
+ var file = itemsList.FirstOrDefault(i => i.Path.Contains("file1.txt"));
+ var directory = itemsList.FirstOrDefault(i => i.Path.Contains("subdir") && i.IsDirectory);
+
+ Assert.NotNull(file);
+ Assert.NotNull(directory);
+ Assert.False(file.IsDirectory);
+ Assert.True(directory.IsDirectory);
+ }
+
+ [Fact]
+ public async Task GetItemAsync_ExistingFile_ReturnsItem() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "get_item_test.txt";
+ var content = "Hello, World!";
+
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Act
+ var item = await _storage.GetItemAsync(filePath);
+
+ // Assert
+ Assert.NotNull(item);
+ Assert.Equal(filePath, item.Path);
+ Assert.False(item.IsDirectory);
+ Assert.Equal(content.Length, item.Size);
+ }
+
+ [Fact]
+ public async Task GetItemAsync_Directory_ReturnsDirectoryItem() {
+ // Arrange
+ _storage = CreateStorage();
+ var dirPath = "get_dir_test";
+ await _storage.CreateDirectoryAsync(dirPath);
+
+ // Act
+ var item = await _storage.GetItemAsync(dirPath);
+
+ // Assert
+ Assert.NotNull(item);
+ Assert.True(item.IsDirectory);
+ Assert.Equal(dirPath, item.Path);
+ }
+
+ [Fact]
+ public async Task GetItemAsync_NonexistentItem_ReturnsNull() {
+ // Arrange
+ _storage = CreateStorage();
+
+ // Act
+ var item = await _storage.GetItemAsync("nonexistent.txt");
+
+ // Assert
+ Assert.Null(item);
+ }
+
+ [Fact]
+ public async Task ComputeHashAsync_ReturnsConsistentHash() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "hash_test.txt";
+ var content = "Hello, World!";
+
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Act
+ var hash1 = await _storage.ComputeHashAsync(filePath);
+ var hash2 = await _storage.ComputeHashAsync(filePath);
+
+ // Assert
+ Assert.NotNull(hash1);
+ Assert.NotEmpty(hash1);
+ Assert.Equal(hash1, hash2);
+ }
+
+ [Fact]
+ public async Task ComputeHashAsync_DifferentContent_DifferentHashes() {
+ // Arrange
+ _storage = CreateStorage();
+ var file1 = "hash1.txt";
+ var file2 = "hash2.txt";
+
+ using var stream1 = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("content 1"));
+ await _storage.WriteFileAsync(file1, stream1);
+
+ using var stream2 = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("content 2"));
+ await _storage.WriteFileAsync(file2, stream2);
+
+ // Act
+ var hash1 = await _storage.ComputeHashAsync(file1);
+ var hash2 = await _storage.ComputeHashAsync(file2);
+
+ // Assert
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public async Task WriteFileAsync_LargeFile_HandlesCorrectly() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "large.bin";
+ var largeContent = new byte[5 * 1024 * 1024]; // 5 MB
+ new Random().NextBytes(largeContent);
+
+ // Act
+ using var stream = new MemoryStream(largeContent);
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(filePath);
+ Assert.True(exists);
+
+ var item = await _storage.GetItemAsync(filePath);
+ Assert.NotNull(item);
+ Assert.Equal(largeContent.Length, item.Size);
+ }
+
+ [Fact]
+ public async Task ReadFileAsync_LargeFile_ReadsCorrectly() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "large_read.bin";
+ var largeContent = new byte[5 * 1024 * 1024]; // 5 MB
+ new Random().NextBytes(largeContent);
+
+ using var writeStream = new MemoryStream(largeContent);
+ await _storage.WriteFileAsync(filePath, writeStream);
+
+ // Act
+ using var readStream = await _storage.ReadFileAsync(filePath);
+ using var memoryStream = new MemoryStream();
+ await readStream.CopyToAsync(memoryStream);
+ var readContent = memoryStream.ToArray();
+
+ // Assert
+ Assert.Equal(largeContent.Length, readContent.Length);
+ Assert.Equal(largeContent, readContent);
+ }
+
+ [Fact]
+ public async Task WriteFileAsync_EmptyFile_CreatesEmptyFile() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "empty.txt";
+
+ // Act
+ using var stream = new MemoryStream();
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(filePath);
+ Assert.True(exists);
+
+ var item = await _storage.GetItemAsync(filePath);
+ Assert.NotNull(item);
+ Assert.Equal(0, item.Size);
+ }
+
+ [Fact]
+ public async Task WriteFileAsync_OverwritesExistingFile() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "overwrite.txt";
+
+ using var originalStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("original content"));
+ await _storage.WriteFileAsync(filePath, originalStream);
+
+ // Act
+ var newContent = "new content";
+ using var newStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(newContent));
+ await _storage.WriteFileAsync(filePath, newStream);
+
+ // Assert
+ using var readStream = await _storage.ReadFileAsync(filePath);
+ using var reader = new StreamReader(readStream);
+ var result = await reader.ReadToEndAsync();
+ Assert.Equal(newContent, result);
+ }
+
+ [Fact]
+ public async Task CreateDirectoryAsync_AlreadyExists_DoesNotThrow() {
+ // Arrange
+ _storage = CreateStorage();
+ var dirPath = "existing_dir";
+ await _storage.CreateDirectoryAsync(dirPath);
+
+ // Act & Assert - should not throw
+ await _storage.CreateDirectoryAsync(dirPath);
+
+ // Verify it still exists
+ var exists = await _storage.ExistsAsync(dirPath);
+ Assert.True(exists);
+ }
+
+ [Fact]
+ public async Task ListItemsAsync_EmptyDirectory_ReturnsEmpty() {
+ // Arrange
+ _storage = CreateStorage();
+ var subdir = "empty_subdir";
+ await _storage.CreateDirectoryAsync(subdir);
+
+ // Act
+ var items = await _storage.ListItemsAsync(subdir);
+
+ // Assert
+ Assert.Empty(items);
+ }
+
+ [Fact]
+ public async Task GetStorageInfoAsync_ReturnsInfo() {
+ // Arrange
+ _storage = CreateStorage();
+
+ // Act
+ var info = await _storage.GetStorageInfoAsync();
+
+ // Assert
+ Assert.NotNull(info);
+ // SFTP doesn't always support storage info, so we just verify it doesn't throw
+ }
+
+ [Fact]
+ public async Task ProgressChanged_LargeFile_RaisesEvents() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "progress_test.bin";
+ var largeContent = new byte[15 * 1024 * 1024]; // 15 MB (larger than chunk size)
+ new Random().NextBytes(largeContent);
+
+ var progressEvents = new List();
+ _storage.ProgressChanged += (sender, args) => progressEvents.Add(args);
+
+ // Act
+ using var stream = new MemoryStream(largeContent);
+ await _storage.WriteFileAsync(filePath, stream);
+
+ // Assert
+ Assert.NotEmpty(progressEvents);
+ Assert.All(progressEvents, e => Assert.Equal(StorageOperation.Upload, e.Operation));
+ Assert.All(progressEvents, e => Assert.Equal(filePath, e.Path));
+ }
+
+ [Theory]
+ [InlineData("file with spaces.txt")]
+ [InlineData("file-with-dashes.txt")]
+ [InlineData("file_with_underscores.txt")]
+ [InlineData("file.multiple.dots.txt")]
+ public async Task WriteFileAsync_SpecialFileNames_HandlesCorrectly(string fileName) {
+ // Arrange
+ _storage = CreateStorage();
+ var content = "test content";
+
+ // Act
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
+ await _storage.WriteFileAsync(fileName, stream);
+
+ // Assert
+ var exists = await _storage.ExistsAsync(fileName);
+ Assert.True(exists);
+ }
+
+ [Fact]
+ public async Task GetItemAsync_IncludesPermissions() {
+ // Arrange
+ _storage = CreateStorage();
+ var filePath = "permissions_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
+ Assert.NotNull(item);
+ Assert.NotNull(item.Permissions);
+ Assert.NotEmpty(item.Permissions);
+ // Permissions should be in format like "-rw-r--r--"
+ Assert.Equal(10, item.Permissions.Length);
+ }
+
+ #endregion
+}
+
+///
+/// Exception to indicate test should be skipped
+///
+public class SkipException: Exception {
+ public SkipException(string message) : base(message) { }
+}