From ce43f1f51f467e04928491a2f6d66447cbf422a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:41:01 +0000 Subject: [PATCH 1/3] Initial plan From 4d85dd96f5bcdde4c87473d26d0089229718f139 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:44:59 +0000 Subject: [PATCH 2/3] Fix SFTP test root path handling for chrooted environments - Handle empty SFTP_TEST_ROOT properly (use relative path instead of /tmp/sharpsync-tests) - When root is empty string (chrooted env), use relative paths for test directories - This fixes "No such file" errors when creating test directories in CI Co-authored-by: Menelion <595597+Menelion@users.noreply.github.com> --- .../SharpSync.Tests/Storage/SftpStorageTests.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs index 9ec0c91..bbf4c14 100644 --- a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs +++ b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs @@ -28,7 +28,11 @@ public SftpStorageTests() { _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"; + + // Use environment variable if set, otherwise default to /tmp/sharpsync-tests + // Note: Empty string means "use root of SFTP server" (for chrooted environments) + var testRootEnv = Environment.GetEnvironmentVariable("SFTP_TEST_ROOT"); + _testRoot = testRootEnv ?? "/tmp/sharpsync-tests"; var portStr = Environment.GetEnvironmentVariable("SFTP_TEST_PORT"); _testPort = int.TryParse(portStr, out var port) ? port : 22; @@ -120,12 +124,18 @@ private void SkipIfIntegrationTestsDisabled() { private SftpStorage CreateStorage() { SkipIfIntegrationTestsDisabled(); + // Create a unique subdirectory for each test to avoid conflicts + var testSubdir = Guid.NewGuid().ToString(); + var rootPath = string.IsNullOrEmpty(_testRoot) + ? testSubdir // When root is empty (chrooted env), use relative path + : $"{_testRoot}/{testSubdir}"; // Otherwise, append to root + if (!string.IsNullOrEmpty(_testKey)) { // Key-based authentication - return new SftpStorage(_testHost!, _testPort, _testUser!, privateKeyPath: _testKey, privateKeyPassphrase: null, rootPath: $"{_testRoot}/{Guid.NewGuid()}"); + return new SftpStorage(_testHost!, _testPort, _testUser!, privateKeyPath: _testKey, privateKeyPassphrase: null, rootPath: rootPath); } else { // Password authentication - return new SftpStorage(_testHost!, _testPort, _testUser!, password: _testPass!, rootPath: $"{_testRoot}/{Guid.NewGuid()}"); + return new SftpStorage(_testHost!, _testPort, _testUser!, password: _testPass!, rootPath: rootPath); } } From cc705c22e37d501e1b8c1c73132c0aefeea9f514 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:52:26 +0000 Subject: [PATCH 3/3] Improve SFTP chroot detection and path handling - Simplify chroot detection: assume relative paths when RootPath doesn't start with "/" - Fix exception handling to catch SftpPathNotFoundException in addition to permission errors - This should fix issues with chrooted SFTP environments in CI - Separate chroot detection from root path creation logic Co-authored-by: Menelion <595597+Menelion@users.noreply.github.com> --- src/SharpSync/Storage/SftpStorage.cs | 129 +++++++++++++-------------- 1 file changed, 61 insertions(+), 68 deletions(-) diff --git a/src/SharpSync/Storage/SftpStorage.cs b/src/SharpSync/Storage/SftpStorage.cs index 2df1be6..2c62e08 100644 --- a/src/SharpSync/Storage/SftpStorage.cs +++ b/src/SharpSync/Storage/SftpStorage.cs @@ -201,90 +201,81 @@ private async Task EnsureConnectedAsync(CancellationToken cancellationToken = de await Task.Run(() => _client.Connect(), cancellationToken); - // Detect server path handling (chrooted vs normal) and set effective root + // Detect server path handling based on root path configuration + // When no root is specified or root doesn't start with "/", assume chrooted environment + // and use relative paths. This is the safe default. var normalizedRoot = string.IsNullOrEmpty(RootPath) ? "" : RootPath.TrimStart('/'); + bool isChrooted = string.IsNullOrEmpty(RootPath) || !RootPath.StartsWith('/'); 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; - } + // No root path specified + _effectiveRoot = null; + _useRelativePaths = isChrooted; } else { try { - // Try to detect which path form the server accepts + // Root path specified - check if it exists or try to create it 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; + // Try different path forms based on server type + if (isChrooted) { + // Chrooted server - use relative paths + if (SafeExists(normalizedRoot)) { + existingRoot = normalizedRoot; + } else { + // Path doesn't exist, try to create it + var parts = normalizedRoot.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList(); + var currentPath = ""; + + foreach (var part in parts) { + currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}"; + + if (!SafeExists(currentPath)) { + try { + _client.CreateDirectory(currentPath); + } catch (Exception ex) when (ex is Renci.SshNet.Common.SftpPermissionDeniedException || + ex is Renci.SshNet.Common.SftpPathNotFoundException) { + // Failed to create - likely at chroot boundary, continue + break; + } + } + } + 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; - } + // Normal server - use absolute paths + if (SafeExists(absoluteRoot)) { + existingRoot = normalizedRoot; + } else { + // Path doesn't exist, try to create it + var parts = normalizedRoot.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList(); + var currentPath = ""; + + foreach (var part in parts) { + currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}"; + var absolutePath = "/" + currentPath; + + if (!SafeExists(absolutePath)) { + try { + _client.CreateDirectory(absolutePath); + } catch (Exception ex) when (ex is Renci.SshNet.Common.SftpPermissionDeniedException || + ex is Renci.SshNet.Common.SftpPathNotFoundException) { + // Failed to create + break; } } } + existingRoot = normalizedRoot; } - - existingRoot = normalizedRoot; - _useRelativePaths = createdWithRelative; + _useRelativePaths = false; } _effectiveRoot = existingRoot; } catch (Renci.SshNet.Common.SftpPermissionDeniedException) { - // Permission errors during detection - assume chrooted/relative behavior + // Permission errors during root path handling - stick with detected server type _effectiveRoot = normalizedRoot; - _useRelativePaths = true; + _useRelativePaths = isChrooted; } } } finally { @@ -516,16 +507,18 @@ await ExecuteWithRetry(async () => { if (!SafeExists(currentPath)) { try { await Task.Run(() => _client!.CreateDirectory(currentPath), cancellationToken); - } catch (Renci.SshNet.Common.SftpPermissionDeniedException) { + } catch (Exception ex) when (ex is Renci.SshNet.Common.SftpPermissionDeniedException || + ex is Renci.SshNet.Common.SftpPathNotFoundException) { // 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) { + } catch (Exception ex2) when (ex2 is Renci.SshNet.Common.SftpPermissionDeniedException || + ex2 is Renci.SshNet.Common.SftpPathNotFoundException) { // Both forms failed - check if either now exists if (!SafeExists(currentPath) && !SafeExists(alternatePath)) { - // Permission denied at chroot boundary - skip this segment + // Permission denied or path not found at chroot boundary - skip this segment // and try to continue with remaining path // This handles chrooted servers where certain path prefixes are inaccessible continue;