diff --git a/src/Renci.SshNet/Sftp/ISftpSession.cs b/src/Renci.SshNet/Sftp/ISftpSession.cs index e49936b7a..ec7e77802 100644 --- a/src/Renci.SshNet/Sftp/ISftpSession.cs +++ b/src/Renci.SshNet/Sftp/ISftpSession.cs @@ -116,6 +116,17 @@ internal interface ISftpSession : ISubsystemSession /// SftpFileAttributes RequestLStat(string path); + /// + /// Asynchronously performs SSH_FXP_LSTAT request. + /// + /// The path. + /// The token to monitor for cancellation requests. + /// + /// A task the represents the asynchronous SSH_FXP_LSTAT request. The value of its + /// contains the file attributes of the specified path. + /// + Task RequestLStatAsync(string path, CancellationToken cancellationToken); + /// /// Performs SSH_FXP_LSTAT request. /// diff --git a/src/Renci.SshNet/Sftp/SftpSession.cs b/src/Renci.SshNet/Sftp/SftpSession.cs index 7f59ac850..a397d66ba 100644 --- a/src/Renci.SshNet/Sftp/SftpSession.cs +++ b/src/Renci.SshNet/Sftp/SftpSession.cs @@ -1031,6 +1031,38 @@ public SftpFileAttributes RequestLStat(string path) return attributes; } + /// + /// Asynchronously performs SSH_FXP_LSTAT request. + /// + /// The path. + /// The token to monitor for cancellation requests. + /// + /// A task the represents the asynchronous SSH_FXP_LSTAT request. The value of its + /// contains the file attributes of the specified path. + /// + public async Task RequestLStatAsync(string path, CancellationToken cancellationToken) + { + 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 SftpLStatRequest(ProtocolVersion, + NextRequestId, + path, + _encoding, + response => tcs.TrySetResult(response.Attributes), + response => tcs.TrySetException(GetSftpException(response)))); + + return await tcs.Task.ConfigureAwait(false); + } + } + /// /// Performs SSH_FXP_LSTAT request. /// diff --git a/src/Renci.SshNet/SftpClient.cs b/src/Renci.SshNet/SftpClient.cs index a666ad041..59fe2377e 100644 --- a/src/Renci.SshNet/SftpClient.cs +++ b/src/Renci.SshNet/SftpClient.cs @@ -689,6 +689,38 @@ public ISftpFile Get(string path) return new SftpFile(_sftpSession, fullPath, attributes); } + /// + /// Gets reference to remote file or directory. + /// + /// The path. + /// The to observe. + /// + /// A that represents the get operation. + /// The task result contains the reference to file object. + /// + /// Client is not connected. + /// was not found on the remote host. + /// is . + /// The method was called after the client was disposed. + public async Task GetAsync(string path, CancellationToken cancellationToken) + { + CheckDisposed(); + ThrowHelper.ThrowIfNull(path); + + if (_sftpSession is null) + { + throw new SshConnectionException("Client not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + + var attributes = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); + + return new SftpFile(_sftpSession, fullPath, attributes); + } + /// /// Checks whether file or directory exists. /// @@ -743,6 +775,64 @@ public bool Exists(string path) } } + /// + /// Checks whether file or directory exists. + /// + /// The path. + /// The to observe. + /// + /// A that represents the exists operation. + /// The task result contains if directory or file exists; otherwise . + /// + /// is or contains only whitespace characters. + /// Client is not connected. + /// Permission to perform the operation 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 ExistsAsync(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); + + /* + * Using SSH_FXP_REALPATH is not an alternative as the SFTP specification has not always + * been clear on how the server should respond when the specified path is not present on + * the server: + * + * SSH 1 to 4: + * No mention of how the server should respond if the path is not present on the server. + * + * SSH 5: + * The server SHOULD fail the request if the path is not present on the server. + * + * SSH 6: + * Draft 06: The server SHOULD fail the request if the path is not present on the server. + * Draft 07 to 13: The server MUST NOT fail the request if the path does not exist. + * + * Note that SSH 6 (draft 06 and forward) allows for more control options, but we + * currently only support up to v3. + */ + + try + { + _ = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); + return true; + } + catch (SftpPathNotFoundException) + { + return false; + } + } + /// /// Downloads remote file specified by the path into the stream. /// diff --git a/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpFileTest.cs b/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpFileTest.cs index 058442511..ec9fb8c76 100644 --- a/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpFileTest.cs +++ b/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpFileTest.cs @@ -87,6 +87,85 @@ public void Test_Get_International_File() Assert.IsFalse(file.IsDirectory); } } + [TestMethod] + [TestCategory("Sftp")] + public async Task Test_Get_Root_DirectoryAsync() + { + using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + sftp.Connect(); + var directory = await sftp.GetAsync("/", default).ConfigureAwait(false); + + Assert.AreEqual("/", directory.FullName); + Assert.IsTrue(directory.IsDirectory); + Assert.IsFalse(directory.IsRegularFile); + } + } + + [TestMethod] + [TestCategory("Sftp")] + [ExpectedException(typeof(SftpPathNotFoundException))] + public async Task Test_Get_Invalid_DirectoryAsync() + { + using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + sftp.Connect(); + + await sftp.GetAsync("/xyz", default).ConfigureAwait(false); + } + } + + [TestMethod] + [TestCategory("Sftp")] + public async Task Test_Get_FileAsync() + { + using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + sftp.Connect(); + + sftp.UploadFile(new MemoryStream(), "abc.txt"); + + var file = await sftp.GetAsync("abc.txt", default).ConfigureAwait(false); + + Assert.AreEqual("/home/sshnet/abc.txt", file.FullName); + Assert.IsTrue(file.IsRegularFile); + Assert.IsFalse(file.IsDirectory); + } + } + + [TestMethod] + [TestCategory("Sftp")] + [Description("Test passing null to Get.")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task Test_Get_File_NullAsync() + { + using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + sftp.Connect(); + + var file = await sftp.GetAsync(null, default).ConfigureAwait(false); + + sftp.Disconnect(); + } + } + + [TestMethod] + [TestCategory("Sftp")] + public async Task Test_Get_International_FileAsync() + { + using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + sftp.Connect(); + + sftp.UploadFile(new MemoryStream(), "test-üöä-"); + + var file = await sftp.GetAsync("test-üöä-", default).ConfigureAwait(false); + + Assert.AreEqual("/home/sshnet/test-üöä-", file.FullName); + Assert.IsTrue(file.IsRegularFile); + Assert.IsFalse(file.IsDirectory); + } + } [TestMethod] [TestCategory("Sftp")] diff --git a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs index a1276e801..49bbf6fae 100644 --- a/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SftpClientTests.cs @@ -61,12 +61,12 @@ public async Task Create_directory_with_contents_and_list_it_async() // Create new directory and check if it exists _sftpClient.CreateDirectory(testDirectory); - Assert.IsTrue(_sftpClient.Exists(testDirectory)); + Assert.IsTrue(await _sftpClient.ExistsAsync(testDirectory)); // Upload file and check if it exists using var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(testContent)); _sftpClient.UploadFile(fileStream, testFilePath); - Assert.IsTrue(_sftpClient.Exists(testFilePath)); + Assert.IsTrue(await _sftpClient.ExistsAsync(testFilePath)); // Check if ListDirectory works var expectedFiles = new List<(string FullName, bool IsRegularFile, bool IsDirectory)>() diff --git a/test/Renci.SshNet.IntegrationTests/SftpTests.cs b/test/Renci.SshNet.IntegrationTests/SftpTests.cs index 1a2c7ebb9..81e1217e1 100644 --- a/test/Renci.SshNet.IntegrationTests/SftpTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SftpTests.cs @@ -3770,6 +3770,138 @@ public void Sftp_Exists() #endregion Teardown } + [TestMethod] + public async Task Sftp_ExistsAsync() + { + const string remoteHome = "/home/sshnet"; + + #region Setup + + using (var client = new SshClient(_connectionInfoFactory.Create())) + { + client.Connect(); + + #region Clean-up + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/DoesNotExist"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/symlink.to.directory.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/directory.exists"}") + ) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/symlink.to.file.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -f {remoteHome + "/file.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + #endregion Clean-up + + #region Setup + + using (var command = client.CreateCommand($"touch {remoteHome + "/file.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"mkdir {remoteHome + "/directory.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"ln -s {remoteHome + "/file.exists"} {remoteHome + "/symlink.to.file.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"ln -s {remoteHome + "/directory.exists"} {remoteHome + "/symlink.to.directory.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + #endregion Setup + } + + #endregion Setup + + #region Assert + + using (var client = new SftpClient(_connectionInfoFactory.Create())) + { + await client.ConnectAsync(default).ConfigureAwait(false); + + Assert.IsFalse(await client.ExistsAsync(remoteHome + "/DoesNotExist")); + Assert.IsTrue(await client.ExistsAsync(remoteHome + "/file.exists")); + Assert.IsTrue(await client.ExistsAsync(remoteHome + "/symlink.to.file.exists")); + Assert.IsTrue(await client.ExistsAsync(remoteHome + "/directory.exists")); + Assert.IsTrue(await client.ExistsAsync(remoteHome + "/symlink.to.directory.exists")); + } + + #endregion Assert + + #region Teardown + + using (var client = new SshClient(_connectionInfoFactory.Create())) + { + client.Connect(); + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/DoesNotExist"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/symlink.to.directory.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/directory.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -Rf {remoteHome + "/symlink.to.file.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + + using (var command = client.CreateCommand($"rm -f {remoteHome + "/file.exists"}")) + { + await command.ExecuteAsync(); + Assert.AreEqual(0, command.ExitStatus, command.Error); + } + } + + #endregion Teardown + } + [TestMethod] public void Sftp_ListDirectory() {