Skip to content

Added support for deleting directories asynchronously #1503

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Sep 26, 2024
22 changes: 22 additions & 0 deletions src/Renci.SshNet/ISftpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ public interface ISftpClient : IBaseClient
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
void Delete(string path);

/// <summary>
/// Permanently deletes a file on remote machine.
/// </summary>
/// <param name="path">The name of the file or directory to be deleted. Wildcard characters are not supported.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous delete operation.</returns>
Task DeleteAsync(string path, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes remote directory specified by path.
/// </summary>
Expand All @@ -508,6 +516,20 @@ public interface ISftpClient : IBaseClient
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
void DeleteDirectory(string path);

/// <summary>
/// Asynchronously deletes a remote directory.
/// </summary>
/// <param name="path">The path of the directory to be deleted.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous delete operation.</returns>
/// <exception cref="ArgumentException"><paramref name="path"/> is <see langword="null"/> or contains only whitespace characters.</exception>
/// <exception cref="SshConnectionException">Client is not connected.</exception>
/// <exception cref="SftpPathNotFoundException"><paramref name="path"/> was not found on the remote host.</exception>
/// <exception cref="SftpPermissionDeniedException">Permission to delete the directory was denied by the remote host. <para>-or-</para> A SSH command was denied by the server.</exception>
/// <exception cref="SshException">A SSH error where <see cref="Exception.Message"/> is the message from the remote host.</exception>
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
Task DeleteDirectoryAsync(string path, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes remote file specified by path.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/Renci.SshNet/Sftp/ISftpFile.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Renci.SshNet.Sftp
{
Expand Down Expand Up @@ -227,6 +229,13 @@ public interface ISftpFile
/// </summary>
void Delete();

/// <summary>
/// Permanently deletes a file on the remote machine.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous delete operation.</returns>
Task DeleteAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Moves a specified file to a new location on remote machine, providing the option to specify a new file name.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/Renci.SshNet/Sftp/ISftpSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,16 @@ internal interface ISftpSession : ISubsystemSession
/// <param name="path">The path.</param>
void RequestRmDir(string path);

/// <summary>
/// Asynchronously performs an SSH_FXP_RMDIR request.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous <c>SSH_FXP_RMDIR</c> request.
/// </returns>
Task RequestRmDirAsync(string path, CancellationToken cancellationToken = default);

/// <summary>
/// Performs SSH_FXP_SETSTAT request.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/Renci.SshNet/Sftp/SftpFile.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

using Renci.SshNet.Common;

Expand Down Expand Up @@ -468,6 +470,14 @@ public void Delete()
}
}

/// <inheritdoc/>
public Task DeleteAsync(CancellationToken cancellationToken = default)
{
return IsDirectory
? _sftpSession.RequestRmDirAsync(FullName, cancellationToken)
: _sftpSession.RequestRemoveAsync(FullName, cancellationToken);
}

/// <summary>
/// Moves a specified file to a new location on remote machine, providing the option to specify a new file name.
/// </summary>
Expand Down
34 changes: 34 additions & 0 deletions src/Renci.SshNet/Sftp/SftpSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,40 @@ public void RequestRmDir(string path)
}
}

/// <inheritdoc />
public async Task RequestRmDirAsync(string path, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

#if NET || NETSTANDARD2_1_OR_GREATER
await using (cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).TrySetCanceled(cancellationToken), tcs, useSynchronizationContext: false).ConfigureAwait(continueOnCapturedContext: false))
#else
using (cancellationToken.Register(s => ((TaskCompletionSource<bool>)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);
}
}

/// <summary>
/// Performs SSH_FXP_REALPATH request.
/// </summary>
Expand Down
38 changes: 26 additions & 12 deletions src/Renci.SshNet/SftpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,24 @@ public void DeleteDirectory(string path)
_sftpSession.RequestRmDir(fullPath);
}

/// <inheritdoc />
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);
}

/// <summary>
/// Deletes remote file specified by path.
/// </summary>
Expand All @@ -449,18 +467,7 @@ public void DeleteFile(string path)
_sftpSession.RequestRemove(fullPath);
}

/// <summary>
/// Asynchronously deletes remote file specified by path.
/// </summary>
/// <param name="path">File to be deleted path.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous delete operation.</returns>
/// <exception cref="ArgumentException"><paramref name="path"/> is <see langword="null"/> or contains only whitespace characters.</exception>
/// <exception cref="SshConnectionException">Client is not connected.</exception>
/// <exception cref="SftpPathNotFoundException"><paramref name="path"/> was not found on the remote host.</exception>
/// <exception cref="SftpPermissionDeniedException">Permission to delete the file was denied by the remote host. <para>-or-</para> A SSH command was denied by the server.</exception>
/// <exception cref="SshException">A SSH error where <see cref="Exception.Message"/> is the message from the remote host.</exception>
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
/// <inheritdoc />
public async Task DeleteFileAsync(string path, CancellationToken cancellationToken)
{
CheckDisposed();
Expand Down Expand Up @@ -1527,6 +1534,13 @@ public void Delete(string path)
file.Delete();
}

/// <inheritdoc />
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
var file = await GetAsync(path, cancellationToken).ConfigureAwait(false);
await file.DeleteAsync(cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Returns the date and time the specified file or directory was last accessed.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
75 changes: 73 additions & 2 deletions test/Renci.SshNet.IntegrationTests/SftpClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -96,6 +96,77 @@ 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
await _sftpClient.CreateDirectoryAsync(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
await _sftpClient.CreateDirectoryAsync(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);

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));
}

[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
await _sftpClient.CreateDirectoryAsync(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();
Expand Down