From 2c93183f3744a19309165b4ea1c9a7a6295308fb Mon Sep 17 00:00:00 2001 From: Ed McIlwraith Date: Mon, 9 Nov 2026 00:15:04 +0000 Subject: [PATCH 1/2] Fix GetCanonicalPathAsync resolving full path instead of parent --- src/Renci.SshNet/Sftp/SftpSession.cs | 58 +++++++++---------- .../SftpSessionTest_ExtractPartialFullPath.cs | 21 +++++++ .../Classes/SftpSessionTest_IsSpecialPath.cs | 25 ++++++++ 3 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs create mode 100644 test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs diff --git a/src/Renci.SshNet/Sftp/SftpSession.cs b/src/Renci.SshNet/Sftp/SftpSession.cs index 041554505..c891d411f 100644 --- a/src/Renci.SshNet/Sftp/SftpSession.cs +++ b/src/Renci.SshNet/Sftp/SftpSession.cs @@ -119,22 +119,13 @@ public string GetCanonicalPath(string path) } // Check for special cases - if (fullPath.EndsWith("/.", StringComparison.OrdinalIgnoreCase) || - fullPath.EndsWith("/..", StringComparison.OrdinalIgnoreCase) || - fullPath.Equals("/", StringComparison.OrdinalIgnoreCase) || - fullPath.IndexOf('/', StringComparison.OrdinalIgnoreCase) < 0) + if (IsSpecialPath(fullPath)) { return fullPath; } var pathParts = fullPath.Split('/'); - - var partialFullPath = string.Join('/', pathParts, 0, pathParts.Length - 1); - - if (string.IsNullOrEmpty(partialFullPath)) - { - partialFullPath = "/"; - } + var partialFullPath = ExtractPartialFullPath(pathParts); realPathFiles = RequestRealPath(partialFullPath, nullOnError: true); @@ -157,6 +148,30 @@ public string GetCanonicalPath(string path) return string.Format(CultureInfo.InvariantCulture, "{0}{1}{2}", canonizedPath, slash, pathParts[pathParts.Length - 1]); } + internal static bool IsSpecialPath(string fullPath) + { + return fullPath.EndsWith("/.", StringComparison.Ordinal) || + fullPath.EndsWith("/..", StringComparison.Ordinal) || + fullPath.Equals("/", StringComparison.Ordinal) || +#if NET + fullPath.IndexOf('/', StringComparison.Ordinal) < 0 +#else + fullPath.IndexOf('/') < 0 +#endif + ; + } + + internal static string ExtractPartialFullPath(string[] pathParts) + { + var partialFullPath = string.Join('/', pathParts, 0, pathParts.Length - 1); + if (string.IsNullOrEmpty(partialFullPath)) + { + partialFullPath = "/"; + } + + return partialFullPath; + } + /// public async Task GetCanonicalPathAsync(string path, CancellationToken cancellationToken) { @@ -175,30 +190,13 @@ public async Task GetCanonicalPathAsync(string path, CancellationToken c } // Check for special cases - if (fullPath.EndsWith("/.", StringComparison.Ordinal) || - fullPath.EndsWith("/..", StringComparison.Ordinal) || - fullPath.Equals("/", StringComparison.Ordinal) || -#if NET - fullPath.IndexOf('/', StringComparison.Ordinal) < 0) -#else - fullPath.IndexOf('/') < 0) -#endif + if (IsSpecialPath(fullPath)) { return fullPath; } var pathParts = fullPath.Split('/'); - -#if NET - var partialFullPath = string.Join('/', pathParts); -#else - var partialFullPath = string.Join("/", pathParts); -#endif - - if (string.IsNullOrEmpty(partialFullPath)) - { - partialFullPath = "/"; - } + var partialFullPath = ExtractPartialFullPath(pathParts); realPathFiles = await RequestRealPathAsync(partialFullPath, nullOnError: true, cancellationToken).ConfigureAwait(false); diff --git a/test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs b/test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs new file mode 100644 index 000000000..75335940a --- /dev/null +++ b/test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs @@ -0,0 +1,21 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Renci.SshNet.Sftp; + +namespace Renci.SshNet.Tests.Classes +{ + + [TestClass] + public class SftpSessionTest_ExtractPartialFullPath + { + [TestMethod] + [DataRow("/a/b/c", "/a/b")] + [DataRow("/c", "/")] + public void Test_ExtractPartialFullPath(string fullPath, string expected) + { + var pathParts = fullPath.Split('/'); + var actual = SftpSession.ExtractPartialFullPath(pathParts); + Assert.AreEqual(expected, actual); + } + } +} \ No newline at end of file diff --git a/test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs b/test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs new file mode 100644 index 000000000..9e2872e44 --- /dev/null +++ b/test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs @@ -0,0 +1,25 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Renci.SshNet.Sftp; + +namespace Renci.SshNet.Tests.Classes +{ + [TestClass] + public class SftpSessionTest_IsSpecialPath + { + [TestMethod] + [DataRow("/", true)] + [DataRow("/.", true)] + [DataRow("/test/path/.", true)] + [DataRow("/..", true)] + [DataRow("/test/path/..", true)] + [DataRow("test", true)] + [DataRow("/test/path", false)] + public void Test_IsSpecialPath(string fullPath, bool expected) + { + var actual = SftpSession.IsSpecialPath(fullPath); + + Assert.AreEqual(expected, actual); + } + } +} \ No newline at end of file From 12c7f205bccc95b7c64bb72f96cfe2a51321247e Mon Sep 17 00:00:00 2001 From: Rob Hague Date: Sat, 14 Nov 2026 13:07:26 +0100 Subject: [PATCH 2/2] Use shared impl --- .../Abstractions/StringExtensions.cs | 4 +- src/Renci.SshNet/Sftp/SftpSession.cs | 95 +++++-------------- .../SftpSessionTest_ExtractPartialFullPath.cs | 21 ---- .../Classes/SftpSessionTest_IsSpecialPath.cs | 25 ----- 4 files changed, 28 insertions(+), 117 deletions(-) delete mode 100644 test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs delete mode 100644 test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs diff --git a/src/Renci.SshNet/Abstractions/StringExtensions.cs b/src/Renci.SshNet/Abstractions/StringExtensions.cs index d62c5684c..58c572213 100644 --- a/src/Renci.SshNet/Abstractions/StringExtensions.cs +++ b/src/Renci.SshNet/Abstractions/StringExtensions.cs @@ -27,7 +27,9 @@ public static string Join(char separator, string?[] value, int startIndex, int c public int IndexOf(char value, StringComparison comparisonType) { - return text.IndexOf(value.ToString(), comparisonType); + return comparisonType == StringComparison.Ordinal + ? text.IndexOf(value) + : text.IndexOf(value.ToString(), comparisonType); } public bool Contains(char value) diff --git a/src/Renci.SshNet/Sftp/SftpSession.cs b/src/Renci.SshNet/Sftp/SftpSession.cs index c891d411f..5c2147efc 100644 --- a/src/Renci.SshNet/Sftp/SftpSession.cs +++ b/src/Renci.SshNet/Sftp/SftpSession.cs @@ -102,11 +102,26 @@ internal void SendMessage(SftpMessage sftpMessage) /// public string GetCanonicalPath(string path) { + return GetCanonicalPathCore(path, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + } + + /// + public Task GetCanonicalPathAsync(string path, CancellationToken cancellationToken) + { + return GetCanonicalPathCore(path, isAsync: true, cancellationToken); + } + + private async Task GetCanonicalPathCore(string path, bool isAsync, CancellationToken cancellationToken) + { + Debug.Assert(isAsync || cancellationToken == default); + var fullPath = GetFullRemotePath(path); var canonizedPath = string.Empty; - var realPathFiles = RequestRealPath(fullPath, nullOnError: true); + var realPathFiles = isAsync + ? await RequestRealPathAsync(fullPath, nullOnError: true, cancellationToken).ConfigureAwait(false) + : RequestRealPath(fullPath, nullOnError: true); if (realPathFiles != null) { @@ -119,86 +134,26 @@ public string GetCanonicalPath(string path) } // Check for special cases - if (IsSpecialPath(fullPath)) + if (fullPath.EndsWith("/.", StringComparison.Ordinal) || + fullPath.EndsWith("/..", StringComparison.Ordinal) || + fullPath.Equals("/", StringComparison.Ordinal) || + fullPath.IndexOf('/', StringComparison.Ordinal) < 0) { return fullPath; } var pathParts = fullPath.Split('/'); - var partialFullPath = ExtractPartialFullPath(pathParts); - - realPathFiles = RequestRealPath(partialFullPath, nullOnError: true); - - if (realPathFiles != null) - { - canonizedPath = realPathFiles[0].Key; - } - - if (string.IsNullOrEmpty(canonizedPath)) - { - return fullPath; - } - - var slash = string.Empty; - if (canonizedPath[canonizedPath.Length - 1] != '/') - { - slash = "/"; - } - - return string.Format(CultureInfo.InvariantCulture, "{0}{1}{2}", canonizedPath, slash, pathParts[pathParts.Length - 1]); - } - - internal static bool IsSpecialPath(string fullPath) - { - return fullPath.EndsWith("/.", StringComparison.Ordinal) || - fullPath.EndsWith("/..", StringComparison.Ordinal) || - fullPath.Equals("/", StringComparison.Ordinal) || -#if NET - fullPath.IndexOf('/', StringComparison.Ordinal) < 0 -#else - fullPath.IndexOf('/') < 0 -#endif - ; - } - internal static string ExtractPartialFullPath(string[] pathParts) - { var partialFullPath = string.Join('/', pathParts, 0, pathParts.Length - 1); + if (string.IsNullOrEmpty(partialFullPath)) { partialFullPath = "/"; } - return partialFullPath; - } - - /// - public async Task GetCanonicalPathAsync(string path, CancellationToken cancellationToken) - { - var fullPath = GetFullRemotePath(path); - - var canonizedPath = string.Empty; - var realPathFiles = await RequestRealPathAsync(fullPath, nullOnError: true, cancellationToken).ConfigureAwait(false); - if (realPathFiles != null) - { - canonizedPath = realPathFiles[0].Key; - } - - if (!string.IsNullOrEmpty(canonizedPath)) - { - return canonizedPath; - } - - // Check for special cases - if (IsSpecialPath(fullPath)) - { - return fullPath; - } - - var pathParts = fullPath.Split('/'); - var partialFullPath = ExtractPartialFullPath(pathParts); - - realPathFiles = await RequestRealPathAsync(partialFullPath, nullOnError: true, cancellationToken).ConfigureAwait(false); + realPathFiles = isAsync + ? await RequestRealPathAsync(partialFullPath, nullOnError: true, cancellationToken).ConfigureAwait(false) + : RequestRealPath(partialFullPath, nullOnError: true); if (realPathFiles != null) { @@ -216,7 +171,7 @@ public async Task GetCanonicalPathAsync(string path, CancellationToken c slash = "/"; } - return canonizedPath + slash + pathParts[pathParts.Length - 1]; + return string.Concat(canonizedPath, slash, pathParts[pathParts.Length - 1]); } internal string GetFullRemotePath(string path) diff --git a/test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs b/test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs deleted file mode 100644 index 75335940a..000000000 --- a/test/Renci.SshNet.Tests/Classes/SftpSessionTest_ExtractPartialFullPath.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -using Renci.SshNet.Sftp; - -namespace Renci.SshNet.Tests.Classes -{ - - [TestClass] - public class SftpSessionTest_ExtractPartialFullPath - { - [TestMethod] - [DataRow("/a/b/c", "/a/b")] - [DataRow("/c", "/")] - public void Test_ExtractPartialFullPath(string fullPath, string expected) - { - var pathParts = fullPath.Split('/'); - var actual = SftpSession.ExtractPartialFullPath(pathParts); - Assert.AreEqual(expected, actual); - } - } -} \ No newline at end of file diff --git a/test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs b/test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs deleted file mode 100644 index 9e2872e44..000000000 --- a/test/Renci.SshNet.Tests/Classes/SftpSessionTest_IsSpecialPath.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -using Renci.SshNet.Sftp; - -namespace Renci.SshNet.Tests.Classes -{ - [TestClass] - public class SftpSessionTest_IsSpecialPath - { - [TestMethod] - [DataRow("/", true)] - [DataRow("/.", true)] - [DataRow("/test/path/.", true)] - [DataRow("/..", true)] - [DataRow("/test/path/..", true)] - [DataRow("test", true)] - [DataRow("/test/path", false)] - public void Test_IsSpecialPath(string fullPath, bool expected) - { - var actual = SftpSession.IsSpecialPath(fullPath); - - Assert.AreEqual(expected, actual); - } - } -} \ No newline at end of file