Skip to content

Add SFTP storage and integration tests#13

Merged
Menelion merged 20 commits into
masterfrom
claude/analyze-storage-implementations-011CUu4KwE4bXVUUcGzDHdLD
Nov 8, 2025
Merged

Add SFTP storage and integration tests#13
Menelion merged 20 commits into
masterfrom
claude/analyze-storage-implementations-011CUu4KwE4bXVUUcGzDHdLD

Conversation

@Menelion

@Menelion Menelion commented Nov 8, 2025

Copy link
Copy Markdown
Contributor
  • Implement SFTP storage with comprehensive tests
  • Add comprehensive integration test infrastructure for CI/CD
  • Fix SftpStorage build errors
  • Add comprehensive XML documentation to all public members in SftpStorage
  • Add XML documentation to Dispose method in SyncEngine
  • Refactor storage classes to use auto-properties
  • Add comprehensive XML documentation to all public members
  • Fix ambiguous SftpStorage constructor calls in tests
  • Fix SFTP server healthcheck wait in integration test scripts
  • Remove version attribute from Docker compose

claude and others added 10 commits November 7, 2025 19:15
Add SftpStorage implementation using SSH.NET library to provide
secure file synchronization over SFTP protocol.

Features:
- Password and private key authentication support
- Progress reporting for large file transfers
- Retry logic with automatic reconnection
- Recursive directory operations
- Unix permission tracking
- Full ISyncStorage interface implementation

Also includes comprehensive test suite with both unit tests
and integration tests (requires real SFTP server).

This makes the existing SFTP claims in package metadata accurate.
Set up Docker-based SFTP server for integration testing in both
CI and local development environments.

Changes:
- GitHub Actions workflow now includes SFTP service container
- Docker Compose configuration for local SFTP testing
- Comprehensive TESTING.md documentation
- Convenience scripts for running integration tests (bash & PowerShell)
- Updated CLAUDE.md to reflect SFTP implementation status

CI Integration:
- Uses atmoz/sftp Docker image with health checks
- Automatically sets environment variables for tests
- Tests run against localhost:2222 in CI

Local Development:
- docker-compose.test.yml for easy local testing
- Scripts handle server lifecycle automatically
- Works on Windows, Linux, and macOS

Documentation Updates:
- Mark SFTP implementation as complete (was listed as "false advertising")
- Update quality metrics: 56% complete (improved from 33%)
- Remove SFTP from v1.1 roadmap (now in v1.0)
- Document integration test patterns and troubleshooting
- Replace internal Permissions property with public boolean properties
  (OwnerCanRead, GroupCanWrite, etc.) per SSH.NET API
- Change StartsWith("/") to StartsWith('/') to fix CA1866 analyzer warning
- Add support for symbolic link detection in permission string

The Permissions property in SftpFileAttributes is internal, not public.
Use the documented public API with individual permission boolean properties.
Add detailed XML documentation for production-ready NuGet library:

Public Properties:
- StorageType: Returns StorageType.Sftp
- RootPath: Root path on SFTP server

Public Methods (ISyncStorage):
- TestConnectionAsync: Tests SFTP connection
- ListItemsAsync: Lists files and directories with metadata
- GetItemAsync: Gets metadata for specific item
- ReadFileAsync: Reads file with progress reporting for large files
- WriteFileAsync: Writes file with automatic parent directory creation
- CreateDirectoryAsync: Creates directories recursively
- DeleteAsync: Deletes files/directories recursively
- MoveAsync: Moves or renames files/directories
- ExistsAsync: Checks if file or directory exists
- GetStorageInfoAsync: Gets storage space (limited SFTP protocol support)
- ComputeHashAsync: Computes SHA256 hash (downloads file)

Public Members:
- ProgressChanged: Event for upload/download progress
- Dispose: Releases resources and disconnects

All documentation includes:
- Summary descriptions
- Parameter documentation
- Return value documentation
- Exception documentation where applicable
- Remarks for special behaviors and caveats

This ensures proper IntelliSense support and API discoverability
for library consumers.
Add comprehensive XML documentation for the Dispose method:
- Summary describes what the method does
- Remarks explain that it cancels ongoing sync operations
- Notes that it can be called multiple times safely
- Clarifies that the engine cannot be reused after disposal

This completes the XML documentation for public members in SyncEngine.
Replace verbose field + property pattern with modern auto-properties:

Before:
  private readonly string _rootPath;
  public string RootPath => _rootPath;

After:
  public string RootPath { get; }

Changes:
- SftpStorage: Convert RootPath to auto-property
- LocalFileStorage: Convert RootPath to auto-property
- WebDavStorage: Convert RootPath to auto-property

Benefits:
- Less boilerplate code
- Modern C# 6.0+ convention
- Functionally identical (compiler generates same IL)
- Easier to read and maintain

All usages of _rootPath fields replaced with RootPath property references.
Fixes all 46 CS1591 build errors (missing XML documentation) by adding
comprehensive XML comments to:

- LocalFileStorage: All public members (14 comments)
- WebDavStorage: All public members (14 comments)
- SqliteSyncDatabase: All public methods (7 comments)
- Core types:
  - SyncOperation enum: All 7 values
  - SyncProgressEventArgs: 3 properties + constructor
  - FileConflictEventArgs: Constructor

All documentation follows C# XML documentation standards with proper
summary, param, returns, exception, and remarks tags where appropriate.
This brings the library to production-ready NuGet package standards.
Resolves CS0121 compiler errors by using named parameters to
disambiguate between password and SSH key authentication constructors.

The two constructors have similar signatures:
- Password: (host, port, username, password, rootPath)
- SSH Key: (host, port, username, keyPath, passphrase, rootPath)

Using named parameters (password:, privateKeyPath:, privateKeyPassphrase:,
rootPath:) makes the intent clear and eliminates ambiguity.

Fixes 3 test compilation errors in SftpStorageTests.cs
The scripts were only waiting 5 seconds before checking if the server
was healthy, but the Docker healthcheck runs every 10 seconds with a
5 second timeout. This meant the script would check before the first
healthcheck even ran.

Changes:
- Bash script: Poll every 2 seconds for up to 60 seconds waiting for
  "healthy" status
- PowerShell script: Same polling logic with progress messages
- Both scripts now show elapsed time during waiting
- Both scripts display server logs if healthcheck fails

This resolves the "SFTP server failed to start" error where the server
was actually starting correctly but just needed more time for the
healthcheck to complete.
@Menelion Menelion added the enhancement Improvement to an existing feature label Nov 8, 2025
@Menelion Menelion self-assigned this Nov 8, 2025
Menelion and others added 10 commits November 8, 2025 15:16
Resolves CI test failures caused by SftpPermissionDeniedException when
EnsureConnectedAsync tries to verify/create the RootPath on chrooted
SFTP servers.

Problem:
- CI SFTP server chroots users to /home/testuser/upload
- Previous code tried to create absolute paths that chrooted users
  cannot create, causing permission denied errors
- This made TestConnectionAsync fail and all subsequent tests fail

Solution:
- Try multiple path candidates (relative and absolute) when checking
  if RootPath exists to account for different chroot configurations
- Create directories incrementally using relative segments first
- If relative creation fails with permission denied, try absolute form
- If both fail, swallow the exception and continue - operations inside
  the user's accessible home directory may still work
- Wrap all root verification in try-catch for SftpPermissionDeniedException
  to prevent connection failures due to chroot restrictions

This makes SftpStorage work correctly with both:
- Standard SFTP servers (absolute paths work)
- Chrooted SFTP servers (relative paths within chroot work)
Resolves CI failures by properly detecting and handling chrooted SFTP
servers that require relative paths instead of absolute paths.

Problem:
- Previous fix only handled connection phase
- All file operations (ListItemsAsync, CreateDirectoryAsync, etc.) still
  used GetFullPath which always returned absolute paths
- Chrooted servers reject absolute paths with SftpPermissionDeniedException
- Tests failed on all file operations

Solution:
1. Added fields to track server behavior:
   - _effectiveRoot: Root path without leading slash
   - _useRelativePaths: True for chrooted servers

2. Enhanced EnsureConnectedAsync to detect path handling:
   - Try relative paths first (chroot-friendly)
   - Try absolute paths as fallback
   - Set _useRelativePaths based on what works
   - Store normalized root in _effectiveRoot

3. Updated GetFullPath to respect chroot behavior:
   - Use relative paths when _useRelativePaths is true
   - Use absolute paths when _useRelativePaths is false
   - Build paths using _effectiveRoot

4. Added SafeExists helper:
   - Try requested path form first
   - If permission denied, try alternate form (relative vs absolute)
   - Prevents permission errors from breaking operations

5. Updated CreateDirectoryAsync:
   - Use SafeExists instead of direct _client.Exists
   - Build paths respecting _useRelativePaths
   - Try alternate form on permission denied

6. Updated ListItemsAsync:
   - Use SafeExists for path existence check

This makes SftpStorage work correctly with:
- Standard SFTP servers (absolute paths)
- Chrooted SFTP servers (relative paths)
- Mixed environments

The implementation auto-detects the server type during connection and
adapts all subsequent operations accordingly.
The CI SFTP server chroots testuser to /home/testuser/upload.
From the SFTP client's perspective after chroot, "/" IS the upload
directory. Setting SFTP_TEST_ROOT to the absolute path
"/home/testuser/upload" caused the client to try accessing
"/home/testuser/upload/home/testuser/upload", resulting in
permission denied errors.

Solution: Set SFTP_TEST_ROOT to empty string, making all test
operations relative to the chroot root (which is the user's
accessible home directory). This aligns with how chrooted SFTP
servers work and allows the chroot detection code in SftpStorage
to properly handle paths.

This fixes CI failures with SftpPermissionDeniedException during:
- Directory creation
- File writes
- File moves
- File deletions
Implements both quick CI fix and robust code improvements to handle
chrooted SFTP servers properly.

CI Fix:
- Changed SFTP_TEST_ROOT from "" to "upload"
- Creates test directories within the chroot boundary where testuser
  has write permissions
- Prevents permission denied errors in CI environment

Code Improvements:

1. Enhanced empty RootPath detection (EnsureConnectedAsync):
   - Previous: Assumed non-chrooted server when RootPath is empty
   - New: Probes server behavior by testing "." vs "/" access
   - Sets _useRelativePaths based on actual server capabilities
   - Defaults to relative paths (safer) if detection fails

2. Improved CreateDirectoryAsync resilience:
   - Previous: Only tried alternate path when _useRelativePaths=true
   - New: Always tries both path forms (relative and absolute) on
     permission denied, regardless of detection result
   - Rethrows only if both forms fail
   - Handles edge cases where server accepts one form but not another

These changes make SftpStorage work correctly with:
- Chrooted SFTP servers (atmoz/sftp, common hosting setups)
- Standard SFTP servers
- Servers with unusual path handling
- Both empty and configured RootPath values

Fixes CI permission denied errors during directory creation, file
writes, file moves, and file deletions.
Resolves test failure in MoveAsync_ToSubdirectory_MovesCorrectly caused
by attempts to create the root directory ("/") or current directory (".").

Problem:
- MoveAsync ensures target parent directory exists before moving
- GetParentDirectory returned "/" for files in root
- CreateDirectoryAsync tried to create "/" or "." as a directory
- SFTP server rejected this with SftpPathNotFoundException
- Subsequent RenameFile failed with "No such file"

Fix:
1. GetParentDirectory now returns empty string instead of "/" for root
   - Callers check `if (!string.IsNullOrEmpty(directory))` before
     calling CreateDirectoryAsync
   - Empty string prevents directory creation attempts for root

2. CreateDirectoryAsync has defensive guard for root/current directory
   - Returns early if fullPath is ".", "/", or empty
   - Prevents spurious CreateDirectory calls that cause server errors
   - Handles edge cases where GetFullPath produces "." due to chroot

These changes prevent attempts to create the server root directory,
which is invalid and causes SftpPathNotFoundException. The
MoveAsync_ToSubdirectory_MovesCorrectly test and similar operations
now work correctly on chrooted SFTP servers.
Resolves MoveAsync_ToSubdirectory_MovesCorrectly test failure caused by
incorrect parent directory computation that mixed absolute/relative path
forms.

Problem:
- MoveAsync computed parent directory from targetFullPath (which could
  be absolute or relative depending on chroot detection)
- Then converted back to relative via GetRelativePath(targetDirectory)
- This conversion failed on chrooted servers, producing wrong paths
- CreateDirectoryAsync didn't create the parent directory
- RenameFile failed with SftpPathNotFoundException "No such file"

Fix:
- Compute parent directory directly from the normalized relative
  targetPath instead of from the full path
- Pass the relative parent path directly to CreateDirectoryAsync
- Avoids path form conversions that can break on chrooted servers

Example:
Before:
  targetPath = "subdir/file.txt"
  targetFullPath = "upload/subdir/file.txt" (or "/upload/subdir/file.txt")
  targetDirectory = GetParentDirectory(targetFullPath) = "upload/subdir"
  GetRelativePath(targetDirectory) = ??? (may not work correctly)

After:
  targetPath = "subdir/file.txt"
  normalizedTargetPath = "subdir/file.txt"
  targetParentRelative = "subdir"
  CreateDirectoryAsync("subdir") ✓ works correctly

This ensures the parent directory is created in the correct form for
both chrooted and standard SFTP servers.
@Menelion Menelion merged commit 31ec878 into master Nov 8, 2025
1 check passed
@Menelion Menelion deleted the claude/analyze-storage-implementations-011CUu4KwE4bXVUUcGzDHdLD branch November 8, 2025 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants