Skip to content

SFTP CreateDirectoryAsync is not idempotent #1747

@JPasterkampRotec

Description

@JPasterkampRotec

My specifications:
OS: Ubuntu Desktop and Windows 11
Framework: .NET 9
Package version: 2025.1.0

This is my current code:

using SftpClient client = new SftpClient(host, port, username, password);
await client.ConnectAsync(cancellationToken);
try
{
        await client.CreateDirectoryAsync(someDir, cancellationToken);
}
catch (SftpException ex)
{
        // Only some logging code here.
}

When this directory already exists, all I get as an exception message is "failure".

Renci.SshNet.Common.SftpException: failure
 at Renci.SshNet.SubsystemSession.<WaitOnHandleAsync> g__DoWaitAsync | 38_0[T](TaskCompletionSource`1 tcs, Int32 millisecondsTimeout, CancellationToken cancellationToken)
 at Renci.SshNet.SftpClient.CreateDirectoryAsync(String path, CancellationToken cancellationToken)

Problems:

  • I can't know for sure if the exception is about the directory already existing, or whether something else is wrong. Type SftpException with message "failure" is just not descriptive enough.
  • CreateDirectoryAsync is not idempotent. Throwing an exception seems very counter-intuitive for a method like this.
  • The SftpException is not described in the method documentation:
        /// <summary>
        /// Asynchronously requests to create a remote directory specified by path.
        /// </summary>
        /// <param name="path">Directory path to create.</param>
        /// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
        /// <returns>A <see cref="Task"/> that represents the asynchronous create directory 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="SftpPermissionDeniedException">Permission to create 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>

If I would want to do this properly, I need quite a lot of code. This is the extension method I wrote:

internal static async ValueTask EnsureDirectoryExists(this SftpClient client, string directory, CancellationToken cancellationToken)
{
    bool directoryExists = await client.ExistsAsync(directory, cancellationToken);
    if (directoryExists) return;

    try
    {
        await client.CreateDirectoryAsync(directory, cancellationToken);
    }
    catch (SftpException ex)
    {
        try
        {
            bool directoryAlreadyExisted= await client.ExistsAsync(directory, cancellationToken);
            if (directoryAlreadyExisted)
            {
                // The directory already existed, so we can ignore the exception.
                return;
            }
            else
            {
                // The directory does not exist.
                throw;
            }
        }
        catch (SftpException)
        {
            // Failed checking if the directory exists.
            // Throwing the original exception as that illustrates what really is the problem here.
            throw ex;
        }
    }
}

There are 2 ways this could be fixed:

  1. Add an EnsureDirectoryExists method. I'm sure this can be done much closer to the actual SFTP protocol than the extension method I created.
  2. Add an SftpDirectoryAlreadyExistsException that inherits from SftpException.

I'm very curious, is there a reason why something like EnsureDirectoryExists was never added to SSH.NET?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions