Skip to content

SFTP CreateDirectoryAsync is not idempotent #1747

Open

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?

Activity

  1. JPasterkampRotec commented on Dec 11, 2025

    @JPasterkampRotec
    Author

    I've looked a bit deeper into the SFTP protocol and from what I can see an SFTP server will always return one of these status codes:

      #define SSH_FX_OK                            0
      #define SSH_FX_EOF                           1
      #define SSH_FX_NO_SUCH_FILE                  2
      #define SSH_FX_PERMISSION_DENIED             3
      #define SSH_FX_FAILURE                       4
      #define SSH_FX_BAD_MESSAGE                   5
      #define SSH_FX_NO_CONNECTION                 6
      #define SSH_FX_CONNECTION_LOST               7
      #define SSH_FX_OP_UNSUPPORTED                8
    

    I assume creating a directory that already exists will always return SSH_FX_FAILURE (4), which is the reason this issue was never addressed.

  2. mus65 commented on Dec 22, 2025

    @mus65
    Contributor

    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.

    As you already noticed, the protocol simply doesn't give a more descriptive error, so there is nothing SSH.NET can do about it.

    CreateDirectoryAsync is not idempotent. Throwing an exception seems very counter-intuitive for a method like this.

    Since SSH.NET can't detect this specific failure, I don't see what else SSH.NET could do. Always checking if the directory exists before is imho not correct, since it introduces another roundtrip to the server.

    The SftpException is not described in the method documentation:

    yeah, that is probably worth fixing (SftpException inherits from SshException though, so the documentation is not completely wrong).

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

    Isn't the whole catch block redundant, since you already checked ExistsAsync previously?

    I get that this seems counter-intutive, but since the protocol doesn't have a proper error code for this, I don't see what SSH.NET could do. I'm not sure a EnsureDirectoryExists would be accepted either since it would be nothing more than doing an exists check beforehand (I'm not a maintainer though).

  3. JPasterkampRotec commented on Dec 22, 2025

    @JPasterkampRotec
    Author

    Isn't the whole catch block redundant, since you already checked ExistsAsync previously?

    It prevents race conditions like:

    1. Consumer 1 checks if the directory exists -> false
    2. Consumer 2 checks if the directory exists -> false
    3. Consumer 1 creates the directory -> success
    4. Consumer 2 creates the directory -> exception

    As you already noticed, the protocol simply doesn't give a more descriptive error, so there is nothing SSH.NET can do about it.

    Yes, I totally understand there is no pretty fix for this, because the protocol does not support it.

  4. 0liver commented on Aug 13, 2026

    @0liver

    I'm not sure an EnsureDirectoryExists would be accepted either

    @WojciechNagorski Wdyt? Would this library accept a convenience method like the one mentioned above?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions