Skip to content

Commit 4c5d0c0

Browse files
Added ExistsAsync and GetAsync to SftpClient (#1501)
* Added ExistsAsync to SftpClient * Added GetAsync to SftpClient --------- Co-authored-by: Rob Hague <rob.hague00@gmail.com>
1 parent 82246e3 commit 4c5d0c0

File tree

6 files changed

+346
-2
lines changed

6 files changed

+346
-2
lines changed

src/Renci.SshNet/Sftp/ISftpSession.cs

+11
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ internal interface ISftpSession : ISubsystemSession
116116
/// </returns>
117117
SftpFileAttributes RequestLStat(string path);
118118

119+
/// <summary>
120+
/// Asynchronously performs SSH_FXP_LSTAT request.
121+
/// </summary>
122+
/// <param name="path">The path.</param>
123+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
124+
/// <returns>
125+
/// A task the represents the asynchronous <c>SSH_FXP_LSTAT</c> request. The value of its
126+
/// <see cref="Task{SftpFileAttributes}.Result"/> contains the file attributes of the specified path.
127+
/// </returns>
128+
Task<SftpFileAttributes> RequestLStatAsync(string path, CancellationToken cancellationToken);
129+
119130
/// <summary>
120131
/// Performs SSH_FXP_LSTAT request.
121132
/// </summary>

src/Renci.SshNet/Sftp/SftpSession.cs

+32
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,38 @@ public SftpFileAttributes RequestLStat(string path)
10311031
return attributes;
10321032
}
10331033

1034+
/// <summary>
1035+
/// Asynchronously performs SSH_FXP_LSTAT request.
1036+
/// </summary>
1037+
/// <param name="path">The path.</param>
1038+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
1039+
/// <returns>
1040+
/// A task the represents the asynchronous <c>SSH_FXP_LSTAT</c> request. The value of its
1041+
/// <see cref="Task{SftpFileAttributes}.Result"/> contains the file attributes of the specified path.
1042+
/// </returns>
1043+
public async Task<SftpFileAttributes> RequestLStatAsync(string path, CancellationToken cancellationToken)
1044+
{
1045+
cancellationToken.ThrowIfCancellationRequested();
1046+
1047+
var tcs = new TaskCompletionSource<SftpFileAttributes>(TaskCreationOptions.RunContinuationsAsynchronously);
1048+
1049+
#if NET || NETSTANDARD2_1_OR_GREATER
1050+
await using (cancellationToken.Register(s => ((TaskCompletionSource<SftpFileAttributes>)s).TrySetCanceled(cancellationToken), tcs, useSynchronizationContext: false).ConfigureAwait(continueOnCapturedContext: false))
1051+
#else
1052+
using (cancellationToken.Register(s => ((TaskCompletionSource<SftpFileAttributes>)s).TrySetCanceled(cancellationToken), tcs, useSynchronizationContext: false))
1053+
#endif // NET || NETSTANDARD2_1_OR_GREATER
1054+
{
1055+
SendRequest(new SftpLStatRequest(ProtocolVersion,
1056+
NextRequestId,
1057+
path,
1058+
_encoding,
1059+
response => tcs.TrySetResult(response.Attributes),
1060+
response => tcs.TrySetException(GetSftpException(response))));
1061+
1062+
return await tcs.Task.ConfigureAwait(false);
1063+
}
1064+
}
1065+
10341066
/// <summary>
10351067
/// Performs SSH_FXP_LSTAT request.
10361068
/// </summary>

src/Renci.SshNet/SftpClient.cs

+90
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,38 @@ public ISftpFile Get(string path)
689689
return new SftpFile(_sftpSession, fullPath, attributes);
690690
}
691691

692+
/// <summary>
693+
/// Gets reference to remote file or directory.
694+
/// </summary>
695+
/// <param name="path">The path.</param>
696+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
697+
/// <returns>
698+
/// A <see cref="Task{ISftpFile}"/> that represents the get operation.
699+
/// The task result contains the reference to <see cref="ISftpFile"/> file object.
700+
/// </returns>
701+
/// <exception cref="SshConnectionException">Client is not connected.</exception>
702+
/// <exception cref="SftpPathNotFoundException"><paramref name="path"/> was not found on the remote host.</exception>
703+
/// <exception cref="ArgumentNullException"><paramref name="path" /> is <see langword="null"/>.</exception>
704+
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
705+
public async Task<ISftpFile> GetAsync(string path, CancellationToken cancellationToken)
706+
{
707+
CheckDisposed();
708+
ThrowHelper.ThrowIfNull(path);
709+
710+
if (_sftpSession is null)
711+
{
712+
throw new SshConnectionException("Client not connected.");
713+
}
714+
715+
cancellationToken.ThrowIfCancellationRequested();
716+
717+
var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false);
718+
719+
var attributes = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false);
720+
721+
return new SftpFile(_sftpSession, fullPath, attributes);
722+
}
723+
692724
/// <summary>
693725
/// Checks whether file or directory exists.
694726
/// </summary>
@@ -743,6 +775,64 @@ public bool Exists(string path)
743775
}
744776
}
745777

778+
/// <summary>
779+
/// Checks whether file or directory exists.
780+
/// </summary>
781+
/// <param name="path">The path.</param>
782+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
783+
/// <returns>
784+
/// A <see cref="Task{T}"/> that represents the exists operation.
785+
/// The task result contains <see langword="true"/> if directory or file exists; otherwise <see langword="false"/>.
786+
/// </returns>
787+
/// <exception cref="ArgumentException"><paramref name="path"/> is <see langword="null"/> or contains only whitespace characters.</exception>
788+
/// <exception cref="SshConnectionException">Client is not connected.</exception>
789+
/// <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>
790+
/// <exception cref="SshException">A SSH error where <see cref="Exception.Message"/> is the message from the remote host.</exception>
791+
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
792+
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
793+
{
794+
CheckDisposed();
795+
ThrowHelper.ThrowIfNullOrWhiteSpace(path);
796+
797+
if (_sftpSession is null)
798+
{
799+
throw new SshConnectionException("Client not connected.");
800+
}
801+
802+
cancellationToken.ThrowIfCancellationRequested();
803+
804+
var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false);
805+
806+
/*
807+
* Using SSH_FXP_REALPATH is not an alternative as the SFTP specification has not always
808+
* been clear on how the server should respond when the specified path is not present on
809+
* the server:
810+
*
811+
* SSH 1 to 4:
812+
* No mention of how the server should respond if the path is not present on the server.
813+
*
814+
* SSH 5:
815+
* The server SHOULD fail the request if the path is not present on the server.
816+
*
817+
* SSH 6:
818+
* Draft 06: The server SHOULD fail the request if the path is not present on the server.
819+
* Draft 07 to 13: The server MUST NOT fail the request if the path does not exist.
820+
*
821+
* Note that SSH 6 (draft 06 and forward) allows for more control options, but we
822+
* currently only support up to v3.
823+
*/
824+
825+
try
826+
{
827+
_ = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false);
828+
return true;
829+
}
830+
catch (SftpPathNotFoundException)
831+
{
832+
return false;
833+
}
834+
}
835+
746836
/// <summary>
747837
/// Downloads remote file specified by the path into the stream.
748838
/// </summary>

test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpFileTest.cs

+79
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,85 @@ public void Test_Get_International_File()
8787
Assert.IsFalse(file.IsDirectory);
8888
}
8989
}
90+
[TestMethod]
91+
[TestCategory("Sftp")]
92+
public async Task Test_Get_Root_DirectoryAsync()
93+
{
94+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
95+
{
96+
sftp.Connect();
97+
var directory = await sftp.GetAsync("/", default).ConfigureAwait(false);
98+
99+
Assert.AreEqual("/", directory.FullName);
100+
Assert.IsTrue(directory.IsDirectory);
101+
Assert.IsFalse(directory.IsRegularFile);
102+
}
103+
}
104+
105+
[TestMethod]
106+
[TestCategory("Sftp")]
107+
[ExpectedException(typeof(SftpPathNotFoundException))]
108+
public async Task Test_Get_Invalid_DirectoryAsync()
109+
{
110+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
111+
{
112+
sftp.Connect();
113+
114+
await sftp.GetAsync("/xyz", default).ConfigureAwait(false);
115+
}
116+
}
117+
118+
[TestMethod]
119+
[TestCategory("Sftp")]
120+
public async Task Test_Get_FileAsync()
121+
{
122+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
123+
{
124+
sftp.Connect();
125+
126+
sftp.UploadFile(new MemoryStream(), "abc.txt");
127+
128+
var file = await sftp.GetAsync("abc.txt", default).ConfigureAwait(false);
129+
130+
Assert.AreEqual("/home/sshnet/abc.txt", file.FullName);
131+
Assert.IsTrue(file.IsRegularFile);
132+
Assert.IsFalse(file.IsDirectory);
133+
}
134+
}
135+
136+
[TestMethod]
137+
[TestCategory("Sftp")]
138+
[Description("Test passing null to Get.")]
139+
[ExpectedException(typeof(ArgumentNullException))]
140+
public async Task Test_Get_File_NullAsync()
141+
{
142+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
143+
{
144+
sftp.Connect();
145+
146+
var file = await sftp.GetAsync(null, default).ConfigureAwait(false);
147+
148+
sftp.Disconnect();
149+
}
150+
}
151+
152+
[TestMethod]
153+
[TestCategory("Sftp")]
154+
public async Task Test_Get_International_FileAsync()
155+
{
156+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
157+
{
158+
sftp.Connect();
159+
160+
sftp.UploadFile(new MemoryStream(), "test-üöä-");
161+
162+
var file = await sftp.GetAsync("test-üöä-", default).ConfigureAwait(false);
163+
164+
Assert.AreEqual("/home/sshnet/test-üöä-", file.FullName);
165+
Assert.IsTrue(file.IsRegularFile);
166+
Assert.IsFalse(file.IsDirectory);
167+
}
168+
}
90169

91170
[TestMethod]
92171
[TestCategory("Sftp")]

test/Renci.SshNet.IntegrationTests/SftpClientTests.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,12 @@ public async Task Create_directory_with_contents_and_list_it_async()
6161

6262
// Create new directory and check if it exists
6363
_sftpClient.CreateDirectory(testDirectory);
64-
Assert.IsTrue(_sftpClient.Exists(testDirectory));
64+
Assert.IsTrue(await _sftpClient.ExistsAsync(testDirectory));
6565

6666
// Upload file and check if it exists
6767
using var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(testContent));
6868
_sftpClient.UploadFile(fileStream, testFilePath);
69-
Assert.IsTrue(_sftpClient.Exists(testFilePath));
69+
Assert.IsTrue(await _sftpClient.ExistsAsync(testFilePath));
7070

7171
// Check if ListDirectory works
7272
var expectedFiles = new List<(string FullName, bool IsRegularFile, bool IsDirectory)>()

0 commit comments

Comments
 (0)