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