From a3c3f0ebc9051f75a8b68dc224f8cac22d042be7 Mon Sep 17 00:00:00 2001 From: Ryan Esteves Date: Mon, 23 Sep 2024 14:12:01 -0400 Subject: [PATCH 1/7] Added support for deleting directories asynchronously --- src/Renci.SshNet/Sftp/ISftpFile.cs | 12 +++++ src/Renci.SshNet/Sftp/ISftpSession.cs | 10 +++++ src/Renci.SshNet/Sftp/SftpFile.cs | 17 +++++++ src/Renci.SshNet/Sftp/SftpSession.cs | 39 ++++++++++++++++ src/Renci.SshNet/SftpClient.cs | 44 +++++++++++++++++++ .../SftpClientTests.cs | 4 +- 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/Renci.SshNet/Sftp/ISftpFile.cs b/src/Renci.SshNet/Sftp/ISftpFile.cs index 02dff7215..7dc995dc8 100644 --- a/src/Renci.SshNet/Sftp/ISftpFile.cs +++ b/src/Renci.SshNet/Sftp/ISftpFile.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; namespace Renci.SshNet.Sftp { @@ -227,6 +229,16 @@ public interface ISftpFile /// void Delete(); + /// + /// Permanently deletes a file on remote machine. + /// + /// The to observe. + /// A that represents the asynchronous delete operation. + /// + /// A reprisenting the delete operation. + /// + Task DeleteAsync(CancellationToken cancellationToken = default); + /// /// Moves a specified file to a new location on remote machine, providing the option to specify a new file name. /// diff --git a/src/Renci.SshNet/Sftp/ISftpSession.cs b/src/Renci.SshNet/Sftp/ISftpSession.cs index ec7e77802..2168f174e 100644 --- a/src/Renci.SshNet/Sftp/ISftpSession.cs +++ b/src/Renci.SshNet/Sftp/ISftpSession.cs @@ -365,6 +365,16 @@ internal interface ISftpSession : ISubsystemSession /// The path. void RequestRmDir(string path); + /// + /// Asynchronously performs an SSH_FXP_RMDIR request. + /// + /// The path. + /// The token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous SSH_FXP_RMDIR request. + /// + Task RequestRmDirAsync(string path, CancellationToken cancellationToken = default); + /// /// Performs SSH_FXP_SETSTAT request. /// diff --git a/src/Renci.SshNet/Sftp/SftpFile.cs b/src/Renci.SshNet/Sftp/SftpFile.cs index 9c6f72a5d..0fbfc39b4 100644 --- a/src/Renci.SshNet/Sftp/SftpFile.cs +++ b/src/Renci.SshNet/Sftp/SftpFile.cs @@ -1,5 +1,7 @@ using System; using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using Renci.SshNet.Common; @@ -468,6 +470,21 @@ public void Delete() } } + /// + /// Asynchronosly requests to permanently delete a file on the remote machine. + /// + /// The to observe. + /// A that represents the asynchronous delete operation. + /// + /// A reprisenting the delete operation. + /// + public Task DeleteAsync(CancellationToken cancellationToken = default) + { + return IsDirectory + ? _sftpSession.RequestRmDirAsync(FullName, cancellationToken) + : _sftpSession.RequestRemoveAsync(FullName, cancellationToken); + } + /// /// Moves a specified file to a new location on remote machine, providing the option to specify a new file name. /// diff --git a/src/Renci.SshNet/Sftp/SftpSession.cs b/src/Renci.SshNet/Sftp/SftpSession.cs index a397d66ba..60abeb06f 100644 --- a/src/Renci.SshNet/Sftp/SftpSession.cs +++ b/src/Renci.SshNet/Sftp/SftpSession.cs @@ -1557,6 +1557,45 @@ public void RequestRmDir(string path) } } + /// + /// Asynchronously performs an SSH_FXP_RMDIR request. + /// + /// The path. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous SSH_FXP_RMDIR request. + public async Task RequestRmDirAsync(string path, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + +#if NET || NETSTANDARD2_1_OR_GREATER + await using (cancellationToken.Register(s => ((TaskCompletionSource)s).TrySetCanceled(cancellationToken), tcs, useSynchronizationContext: false).ConfigureAwait(continueOnCapturedContext: false)) +#else + using (cancellationToken.Register(s => ((TaskCompletionSource)s).TrySetCanceled(cancellationToken), tcs, useSynchronizationContext: false)) +#endif // NET || NETSTANDARD2_1_OR_GREATER + { + SendRequest(new SftpRmDirRequest(ProtocolVersion, + NextRequestId, + path, + _encoding, + response => + { + var exception = GetSftpException(response); + if (exception is not null) + { + tcs.TrySetException(exception); + } + else + { + tcs.TrySetResult(true); + } + })); + + _ = await tcs.Task.ConfigureAwait(false); + } + } + /// /// Performs SSH_FXP_REALPATH request. /// diff --git a/src/Renci.SshNet/SftpClient.cs b/src/Renci.SshNet/SftpClient.cs index 59fe2377e..5348793a4 100644 --- a/src/Renci.SshNet/SftpClient.cs +++ b/src/Renci.SshNet/SftpClient.cs @@ -371,6 +371,35 @@ public void DeleteDirectory(string path) _sftpSession.RequestRmDir(fullPath); } + /// + /// Asynchronously deletes remote directory specified by path. + /// + /// Directory to be deleted path. + /// The to observe. + /// A that represents the asynchronous delete operation. + /// is or contains only whitespace characters. + /// Client is not connected. + /// was not found on the remote host. + /// Permission to delete the directory was denied by the remote host. -or- A SSH command was denied by the server. + /// A SSH error where is the message from the remote host. + /// The method was called after the client was disposed. + public async Task DeleteDirectoryAsync(string path, CancellationToken cancellationToken = default) + { + CheckDisposed(); + ThrowHelper.ThrowIfNullOrWhiteSpace(path); + + if (_sftpSession is null) + { + throw new SshConnectionException("Client not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + + await _sftpSession.RequestRmDirAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + /// /// Deletes remote file specified by path. /// @@ -1474,6 +1503,21 @@ public void Delete(string path) file.Delete(); } + /// + /// Permanently deletes a file on remote machine. + /// + /// The name of the file or directory to be deleted. Wildcard characters are not supported. + /// The to observe. + /// A that represents the asynchronous delete operation. + /// + /// A reprisenting the delete operation. + /// + public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) + { + var file = await GetAsync(path, cancellationToken).ConfigureAwait(false); + await file.DeleteAsync(cancellationToken).ConfigureAwait(false); + } + /// /// Returns the date and time the specified file or directory was last accessed. /// diff --git a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs index 49bbf6fae..75b4191df 100644 --- a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs @@ -83,8 +83,8 @@ public async Task Create_directory_with_contents_and_list_it_async() actualFiles.Add((file.FullName, file.IsRegularFile, file.IsDirectory)); } - _sftpClient.DeleteFile(testFilePath); - _sftpClient.DeleteDirectory(testDirectory); + await _sftpClient.DeleteFileAsync(testFilePath, CancellationToken.None); + await _sftpClient.DeleteDirectoryAsync(testDirectory, CancellationToken.None); CollectionAssert.AreEquivalent(expectedFiles, actualFiles); } From a64a77e2806a8b1fe860fc68e9e7daf93eef91da Mon Sep 17 00:00:00 2001 From: Ryan Esteves Date: Tue, 24 Sep 2024 19:16:40 -0400 Subject: [PATCH 2/7] Clarify that the task represents the asynchronous delete operation Co-authored-by: Rob Hague --- src/Renci.SshNet/Sftp/ISftpFile.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Renci.SshNet/Sftp/ISftpFile.cs b/src/Renci.SshNet/Sftp/ISftpFile.cs index 7dc995dc8..9ed99a692 100644 --- a/src/Renci.SshNet/Sftp/ISftpFile.cs +++ b/src/Renci.SshNet/Sftp/ISftpFile.cs @@ -230,13 +230,10 @@ public interface ISftpFile void Delete(); /// - /// Permanently deletes a file on remote machine. + /// Permanently deletes a file on the remote machine. /// /// The to observe. /// A that represents the asynchronous delete operation. - /// - /// A reprisenting the delete operation. - /// Task DeleteAsync(CancellationToken cancellationToken = default); /// From 204636b44197d00ee87d8f3dae67b5f74a7a8162 Mon Sep 17 00:00:00 2001 From: Ryan Esteves Date: Wed, 25 Sep 2024 07:56:39 -0400 Subject: [PATCH 3/7] Added DeleteAsync and DeleteDirectoryAsync to ISftpClient --- src/Renci.SshNet/ISftpClient.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Renci.SshNet/ISftpClient.cs b/src/Renci.SshNet/ISftpClient.cs index 7122ccdff..562861c2e 100644 --- a/src/Renci.SshNet/ISftpClient.cs +++ b/src/Renci.SshNet/ISftpClient.cs @@ -483,6 +483,14 @@ public interface ISftpClient : IBaseClient /// The method was called after the client was disposed. void Delete(string path); + /// + /// Permanently deletes a file on remote machine. + /// + /// The name of the file or directory to be deleted. Wildcard characters are not supported. + /// The to observe. + /// A that represents the asynchronous delete operation. + Task DeleteAsync(string path, CancellationToken cancellationToken = default); + /// /// Deletes remote directory specified by path. /// @@ -495,6 +503,20 @@ public interface ISftpClient : IBaseClient /// The method was called after the client was disposed. void DeleteDirectory(string path); + /// + /// Asynchronously deletes a remote directory. + /// + /// The path of the directory to be deleted. + /// The to observe. + /// A that represents the asynchronous delete operation. + /// is or contains only whitespace characters. + /// Client is not connected. + /// was not found on the remote host. + /// Permission to delete the directory was denied by the remote host. -or- A SSH command was denied by the server. + /// A SSH error where is the message from the remote host. + /// The method was called after the client was disposed. + Task DeleteDirectoryAsync(string path, CancellationToken cancellationToken = default); + /// /// Deletes remote file specified by path. /// From 23eebccf08f0842896d88642943c4f2ed7553ca3 Mon Sep 17 00:00:00 2001 From: Ryan Esteves Date: Wed, 25 Sep 2024 08:01:59 -0400 Subject: [PATCH 4/7] Inherit docs from interface --- src/Renci.SshNet/Sftp/SftpSession.cs | 7 +----- src/Renci.SshNet/SftpClient.cs | 36 +++------------------------- 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/src/Renci.SshNet/Sftp/SftpSession.cs b/src/Renci.SshNet/Sftp/SftpSession.cs index 93abae278..df92d24cb 100644 --- a/src/Renci.SshNet/Sftp/SftpSession.cs +++ b/src/Renci.SshNet/Sftp/SftpSession.cs @@ -1575,12 +1575,7 @@ public void RequestRmDir(string path) } } - /// - /// Asynchronously performs an SSH_FXP_RMDIR request. - /// - /// The path. - /// The token to monitor for cancellation requests. - /// A task that represents the asynchronous SSH_FXP_RMDIR request. + /// public async Task RequestRmDirAsync(string path, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Renci.SshNet/SftpClient.cs b/src/Renci.SshNet/SftpClient.cs index 942995e17..280fcf566 100644 --- a/src/Renci.SshNet/SftpClient.cs +++ b/src/Renci.SshNet/SftpClient.cs @@ -398,18 +398,7 @@ public void DeleteDirectory(string path) _sftpSession.RequestRmDir(fullPath); } - /// - /// Asynchronously deletes remote directory specified by path. - /// - /// Directory to be deleted path. - /// The to observe. - /// A that represents the asynchronous delete operation. - /// is or contains only whitespace characters. - /// Client is not connected. - /// was not found on the remote host. - /// Permission to delete the directory was denied by the remote host. -or- A SSH command was denied by the server. - /// A SSH error where is the message from the remote host. - /// The method was called after the client was disposed. + /// public async Task DeleteDirectoryAsync(string path, CancellationToken cancellationToken = default) { CheckDisposed(); @@ -452,18 +441,7 @@ public void DeleteFile(string path) _sftpSession.RequestRemove(fullPath); } - /// - /// Asynchronously deletes remote file specified by path. - /// - /// File to be deleted path. - /// The to observe. - /// A that represents the asynchronous delete operation. - /// is or contains only whitespace characters. - /// Client is not connected. - /// was not found on the remote host. - /// Permission to delete the file was denied by the remote host. -or- A SSH command was denied by the server. - /// A SSH error where is the message from the remote host. - /// The method was called after the client was disposed. + /// public async Task DeleteFileAsync(string path, CancellationToken cancellationToken) { CheckDisposed(); @@ -1530,15 +1508,7 @@ public void Delete(string path) file.Delete(); } - /// - /// Permanently deletes a file on remote machine. - /// - /// The name of the file or directory to be deleted. Wildcard characters are not supported. - /// The to observe. - /// A that represents the asynchronous delete operation. - /// - /// A reprisenting the delete operation. - /// + /// public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) { var file = await GetAsync(path, cancellationToken).ConfigureAwait(false); From 12555a881787491f150cf2815c096f100f923bf8 Mon Sep 17 00:00:00 2001 From: Ryan Esteves Date: Wed, 25 Sep 2024 08:15:03 -0400 Subject: [PATCH 5/7] Added additional tests for new async delete functions --- .../SftpClientTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs index 75b4191df..a5c21e04d 100644 --- a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs @@ -96,6 +96,74 @@ public void Test_Sftp_ListDirectory_Permission_Denied() _sftpClient.ListDirectory("/root"); } + [TestMethod] + public async Task Create_directory_and_delete_it_async() + { + var testDirectory = "/home/sshnet/sshnet-test"; + + // Create new directory and check if it exists + _sftpClient.CreateDirectory(testDirectory); + Assert.IsTrue(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + + await _sftpClient.DeleteDirectoryAsync(testDirectory, CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + } + + [TestMethod] + public async Task Create_directory_with_contents_and_delete_contents_then_directory_async() + { + var testDirectory = "/home/sshnet/sshnet-test"; + var testFileName = "test-file.txt"; + var testFilePath = $"{testDirectory}/{testFileName}"; + var testContent = "file content"; + + // Create new directory and check if it exists + _sftpClient.CreateDirectory(testDirectory); + Assert.IsTrue(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + + // Upload file and check if it exists + using var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(testContent)); + _sftpClient.UploadFile(fileStream, testFilePath); + Assert.IsTrue(await _sftpClient.ExistsAsync(testFilePath).ConfigureAwait(false)); + + await _sftpClient.DeleteFileAsync(testFilePath, CancellationToken.None).ConfigureAwait(false); + await _sftpClient.DeleteDirectoryAsync(testDirectory, CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + Assert.IsFalse(await _sftpClient.ExistsAsync(testFilePath).ConfigureAwait(false)); + } + + [TestMethod] + public async Task Create_directory_and_delete_it_using_DeleteAsync() + { + var testDirectory = "/home/sshnet/sshnet-test"; + + // Create new directory and check if it exists + _sftpClient.CreateDirectory(testDirectory); + Assert.IsTrue(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + + await _sftpClient.DeleteAsync(testDirectory, CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + } + + [TestMethod] + public async Task Create_file_and_delete_using_DeleteAsync() + { + var testFileName = "test-file.txt"; + var testContent = "file content"; + + // Upload file and check if it exists + using var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(testContent)); + _sftpClient.UploadFile(fileStream, testFileName); + Assert.IsTrue(await _sftpClient.ExistsAsync(testFileName).ConfigureAwait(false)); + + await _sftpClient.DeleteAsync(testFileName, CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(await _sftpClient.ExistsAsync(testFileName).ConfigureAwait(false)); + } + public void Dispose() { _sftpClient.Disconnect(); From 6158de590295431e62101f95e563e2b7927fba16 Mon Sep 17 00:00:00 2001 From: Ryan Esteves Date: Wed, 25 Sep 2024 07:51:56 -0400 Subject: [PATCH 6/7] Update list directory test to use async delete methods --- .../OldIntegrationTests/SftpClientTest.ListDirectory.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpClientTest.ListDirectory.cs b/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpClientTest.ListDirectory.cs index 59fefb480..c3c963012 100644 --- a/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpClientTest.ListDirectory.cs +++ b/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpClientTest.ListDirectory.cs @@ -287,10 +287,10 @@ public async Task Test_Sftp_Change_DirectoryAsync() await sftp.ChangeDirectoryAsync("../../", CancellationToken.None).ConfigureAwait(false); - sftp.DeleteDirectory("test1/test1_1"); - sftp.DeleteDirectory("test1/test1_2"); - sftp.DeleteDirectory("test1/test1_3"); - sftp.DeleteDirectory("test1"); + await sftp.DeleteDirectoryAsync("test1/test1_1", CancellationToken.None).ConfigureAwait(false); + await sftp.DeleteDirectoryAsync("test1/test1_2", CancellationToken.None).ConfigureAwait(false); + await sftp.DeleteDirectoryAsync("test1/test1_3", CancellationToken.None).ConfigureAwait(false); + await sftp.DeleteDirectoryAsync("test1", CancellationToken.None).ConfigureAwait(false); sftp.Disconnect(); } From 3d681189b08eb79e3a6332fcd3f00a90f9667f92 Mon Sep 17 00:00:00 2001 From: Rob Hague Date: Wed, 25 Sep 2024 23:29:43 +0200 Subject: [PATCH 7/7] x --- src/Renci.SshNet/Sftp/SftpFile.cs | 9 +-------- test/Renci.SshNet.IntegrationTests/SftpClientTests.cs | 5 ++++- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Renci.SshNet/Sftp/SftpFile.cs b/src/Renci.SshNet/Sftp/SftpFile.cs index 0fbfc39b4..44694b5be 100644 --- a/src/Renci.SshNet/Sftp/SftpFile.cs +++ b/src/Renci.SshNet/Sftp/SftpFile.cs @@ -470,14 +470,7 @@ public void Delete() } } - /// - /// Asynchronosly requests to permanently delete a file on the remote machine. - /// - /// The to observe. - /// A that represents the asynchronous delete operation. - /// - /// A reprisenting the delete operation. - /// + /// public Task DeleteAsync(CancellationToken cancellationToken = default) { return IsDirectory diff --git a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs index 99418be99..951fdf666 100644 --- a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs @@ -128,10 +128,13 @@ public async Task Create_directory_with_contents_and_delete_contents_then_direct Assert.IsTrue(await _sftpClient.ExistsAsync(testFilePath).ConfigureAwait(false)); await _sftpClient.DeleteFileAsync(testFilePath, CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(await _sftpClient.ExistsAsync(testFilePath).ConfigureAwait(false)); + Assert.IsTrue(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); + await _sftpClient.DeleteDirectoryAsync(testDirectory, CancellationToken.None).ConfigureAwait(false); Assert.IsFalse(await _sftpClient.ExistsAsync(testDirectory).ConfigureAwait(false)); - Assert.IsFalse(await _sftpClient.ExistsAsync(testFilePath).ConfigureAwait(false)); } [TestMethod]