Skip to content

Add UploadFileAsync and DownloadFileAsync methods #1634

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 24 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/Renci.SshNet/ISftpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,25 @@ public interface ISftpClient : IBaseClient
/// </remarks>
void DownloadFile(string path, Stream output, Action<ulong>? downloadCallback = null);

/// <summary>
/// Asynchronously downloads remote file specified by the path into the stream.
/// </summary>
/// <param name="path">File to download.</param>
/// <param name="output">Stream to write the file into.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous download operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="output" /> is <see langword="null"/>.</exception>
/// <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="SftpPermissionDeniedException">Permission to perform the operation was denied by the remote host. <para>-or-</para> A SSH command was denied by the server.</exception>
/// <exception cref="SftpPathNotFoundException"><paramref name="path"/> was not found on the remote host.</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>
/// <remarks>
/// Method calls made by this method to <paramref name="output" />, may under certain conditions result in exceptions thrown by the stream.
/// </remarks>
Task DownloadFileAsync(string path, Stream output, CancellationToken cancellationToken = default);

/// <summary>
/// Ends an asynchronous file downloading into the stream.
/// </summary>
Expand Down Expand Up @@ -1104,6 +1123,37 @@ public interface ISftpClient : IBaseClient
/// </remarks>
void UploadFile(Stream input, string path, bool canOverride, Action<ulong>? uploadCallback = null);

/// <summary>
/// Asynchronously uploads stream into remote file.
/// </summary>
/// <param name="input">Data input stream.</param>
/// <param name="path">Remote file path.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous upload operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="input" /> is <see langword="null"/>.</exception>
/// <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="SftpPermissionDeniedException">Permission to upload 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>
Task UploadFileAsync(Stream input, string path, CancellationToken cancellationToken = default);

/// <summary>
/// Asynchronously uploads stream into remote file.
/// </summary>
/// <param name="input">Data input stream.</param>
/// <param name="path">Remote file path.</param>
/// <param name="canOverride">if set to <see langword="true"/> then existing file will be overwritten.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous upload operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="input" /> is <see langword="null"/>.</exception>
/// <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="SftpPermissionDeniedException">Permission to upload 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>
Task UploadFileAsync(Stream input, string path, bool canOverride, CancellationToken cancellationToken = default);

/// <summary>
/// Writes the specified byte array to the specified file, and closes the file.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Renci.SshNet/Sftp/SftpFileStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ public TimeSpan Timeout
}
}

private SftpFileStream(ISftpSession session, string path, FileAccess access, int bufferSize, byte[] handle, long position)
internal SftpFileStream(ISftpSession session, string path, FileAccess access, int bufferSize, byte[] handle, long position)
{
Timeout = TimeSpan.FromSeconds(30);
Name = path;
Expand Down
114 changes: 114 additions & 0 deletions src/Renci.SshNet/SftpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,30 @@ public void DownloadFile(string path, Stream output, Action<ulong>? downloadCall
InternalDownloadFile(path, output, asyncResult: null, downloadCallback);
}

/// <summary>
/// Asynchronously downloads remote file specified by the path into the stream.
/// </summary>
/// <param name="path">File to download.</param>
/// <param name="output">Stream to write the file into.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous download operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="output" /> is <see langword="null"/>.</exception>
/// <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="SftpPermissionDeniedException">Permission to perform the operation was denied by the remote host. <para>-or-</para> A SSH command was denied by the server.</exception>
/// <exception cref="SftpPathNotFoundException"><paramref name="path"/> was not found on the remote host.</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>
/// <remarks>
/// Method calls made by this method to <paramref name="output" />, may under certain conditions result in exceptions thrown by the stream.
/// </remarks>
public Task DownloadFileAsync(string path, Stream output, CancellationToken cancellationToken = default)
{
CheckDisposed();

return InternalDownloadFileAsync(path, output, cancellationToken);
}

/// <summary>
/// Begins an asynchronous file downloading into the stream.
/// </summary>
Expand Down Expand Up @@ -1077,6 +1101,56 @@ public void UploadFile(Stream input, string path, bool canOverride, Action<ulong
InternalUploadFile(input, path, flags, asyncResult: null, uploadCallback);
}

/// <summary>
/// Asynchronously uploads stream into remote file.
/// </summary>
/// <param name="input">Data input stream.</param>
/// <param name="path">Remote file path.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous upload operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="input" /> is <see langword="null"/>.</exception>
/// <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="SftpPermissionDeniedException">Permission to upload 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>
public Task UploadFileAsync(Stream input, string path, CancellationToken cancellationToken = default)
{
return UploadFileAsync(input, path, canOverride: true, cancellationToken: cancellationToken);
}

/// <summary>
/// Asynchronously uploads stream into remote file.
/// </summary>
/// <param name="input">Data input stream.</param>
/// <param name="path">Remote file path.</param>
/// <param name="canOverride">if set to <see langword="true"/> then existing file will be overwritten.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous upload operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="input" /> is <see langword="null"/>.</exception>
/// <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="SftpPermissionDeniedException">Permission to upload 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>
public Task UploadFileAsync(Stream input, string path, bool canOverride, CancellationToken cancellationToken = default)
{
CheckDisposed();

var flags = Flags.Write | Flags.Truncate;

if (canOverride)
{
flags |= Flags.CreateNewOrOpen;
}
else
{
flags |= Flags.CreateNew;
}

return InternalUploadFileAsync(input, path, flags, cancellationToken);
}

/// <summary>
/// Begins an asynchronous uploading the stream into remote file.
/// </summary>
Expand Down Expand Up @@ -2433,6 +2507,26 @@ private void InternalDownloadFile(string path, Stream output, SftpDownloadAsyncR
}
}

private async Task InternalDownloadFileAsync(string path, Stream output, CancellationToken cancellationToken)
{
ThrowHelper.ThrowIfNull(output);
ThrowHelper.ThrowIfNullOrWhiteSpace(path);

if (_sftpSession is null)
{
throw new SshConnectionException("Client not connected.");
}

var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false);
var handle = await _sftpSession.RequestOpenAsync(fullPath, Flags.Read, cancellationToken).ConfigureAwait(false);

using (var input = new SftpFileStream(_sftpSession, fullPath, FileAccess.Read, (int)_bufferSize, handle, 0L))
{
var bufferSize = (int)_sftpSession.CalculateOptimalReadLength(_bufferSize);
await input.CopyToAsync(output, bufferSize, cancellationToken).ConfigureAwait(false);
}
}

/// <summary>
/// Internals the upload file.
/// </summary>
Expand Down Expand Up @@ -2515,6 +2609,26 @@ private void InternalUploadFile(Stream input, string path, Flags flags, SftpUplo
responseReceivedWaitHandle.Dispose();
}

private async Task InternalUploadFileAsync(Stream input, string path, Flags flags, CancellationToken cancellationToken)
{
ThrowHelper.ThrowIfNull(input);
ThrowHelper.ThrowIfNullOrWhiteSpace(path);

if (_sftpSession is null)
{
throw new SshConnectionException("Client not connected.");
}

var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false);
var handle = await _sftpSession.RequestOpenAsync(fullPath, Flags.Write | flags, cancellationToken).ConfigureAwait(false);

using (var output = new SftpFileStream(_sftpSession, fullPath, FileAccess.Write, (int)_bufferSize, handle, 0L))
{
var bufferSize = (int)_sftpSession.CalculateOptimalWriteLength(_bufferSize, handle);
await input.CopyToAsync(output, bufferSize, cancellationToken).ConfigureAwait(false);
}
}

/// <summary>
/// Called when client is connected to the server.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ public void Test_Sftp_Download_File_Not_Exists()
}
}

[TestMethod]
[TestCategory("Sftp")]
public async Task Test_Sftp_DownloadAsync_Forbidden()
{
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, AdminUser.UserName, AdminUser.Password))
{
await sftp.ConnectAsync(CancellationToken.None).ConfigureAwait(false);

await Assert.ThrowsExceptionAsync<SftpPermissionDeniedException>(() => sftp.DownloadFileAsync("/root/.profile", Stream.Null));
}
}

[TestMethod]
[TestCategory("Sftp")]
public async Task Test_Sftp_DownloadAsync_File_Not_Exists()
{
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
{
await sftp.ConnectAsync(CancellationToken.None).ConfigureAwait(false);

await Assert.ThrowsExceptionAsync<SftpPathNotFoundException>(() => sftp.DownloadFileAsync("/xxx/eee/yyy", Stream.Null));
}
}

[TestMethod]
[TestCategory("Sftp")]
[Description("Test passing null to BeginDownloadFile")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,49 @@ public void Test_Sftp_Upload_And_Download_1MB_File()
}
}

[TestMethod]
[TestCategory("Sftp")]
public async Task Test_Sftp_Upload_And_Download_Async_1MB_File()
{
RemoveAllFiles();

using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
{
await sftp.ConnectAsync(CancellationToken.None).ConfigureAwait(false);

var uploadedFileName = Path.GetTempFileName();
var remoteFileName = Path.GetRandomFileName();

await CreateTestFileAsync(uploadedFileName, 1, CancellationToken.None).ConfigureAwait(false);

// Calculate has value
var uploadedHash = await CalculateMD5Async(uploadedFileName, CancellationToken.None).ConfigureAwait(false);

using (var file = File.OpenRead(uploadedFileName))
{
await sftp.UploadFileAsync(file, remoteFileName).ConfigureAwait(false);
}

var downloadedFileName = Path.GetTempFileName();

using (var file = File.OpenWrite(downloadedFileName))
{
await sftp.DownloadFileAsync(remoteFileName, file).ConfigureAwait(false);
}

var downloadedHash = await CalculateMD5Async(downloadedFileName, CancellationToken.None).ConfigureAwait(false);

await sftp.DeleteFileAsync(remoteFileName, CancellationToken.None).ConfigureAwait(false);

File.Delete(uploadedFileName);
File.Delete(downloadedFileName);

sftp.Disconnect();

Assert.AreEqual(uploadedHash, downloadedHash);
}
}

[TestMethod]
[TestCategory("Sftp")]
public void Test_Sftp_Upload_Forbidden()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ protected static string CalculateMD5(string fileName)
}
}

protected static async Task<string> CalculateMD5Async(string fileName, CancellationToken cancellationToken)
{
using (FileStream file = new FileStream(fileName, FileMode.Open))
{
byte[] hash;
using (var md5 = MD5.Create())
{
#if NET48
hash = md5.ComputeHash(file);
await Task.CompletedTask.ConfigureAwait(false);
#else
hash = await md5.ComputeHashAsync(file, cancellationToken).ConfigureAwait(false);
#endif
}

file.Close();

StringBuilder sb = new StringBuilder();
for (var i = 0; i < hash.Length; i++)
{
sb.Append(hash[i].ToString("x2"));
}
return sb.ToString();
}
}

private void RemoveAllFiles()
{
using (var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
Expand Down
Loading