using Renci.SshNet;
using Renci.SshNet.Common;
using Renci.SshNet.Sftp;
namespace DodoSSH.Client.Ssh;
/// An SSH.NET-backed file-transfer session.
///
/// Thin on purpose. Everything above this reasons about and ,
/// which is what keeps the transfer queue and the file browser testable without a server — and the one thing
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
/// caller can act on.
///
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
: ISftpSession
{
///
public bool IsConnected => client.IsConnected;
///
public HostKeyPresentation HostKey { get; } = hostKey;
///
public string HomeDirectory { get; } = homeDirectory;
///
public async Task> ListAsync(string path, CancellationToken cancellationToken)
{
var entries = new List();
try
{
await foreach (var file in client
.ListDirectoryAsync(path, cancellationToken)
.ConfigureAwait(false))
{
// Every directory has these and no user is choosing between them. The way up is the
// breadcrumb trail, which cannot be mistaken for a file.
if (file.Name is "." or "..")
{
continue;
}
entries.Add(Describe(file));
}
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
// Directories first and then by name, which is the order a file browser shows and the order that
// saves every caller sorting it again. Ordinal, because a remote filesystem's names are bytes the
// server never claimed a culture for, and a listing whose order depended on this machine's locale
// would put the same directory in two orders on two of a user's machines.
entries.Sort(static (left, right) => left.Kind == right.Kind
? string.CompareOrdinal(left.Name, right.Name)
: Rank(left.Kind).CompareTo(Rank(right.Kind)));
return entries;
}
///
public async Task StatAsync(string path, CancellationToken cancellationToken)
{
try
{
var file = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);
return Describe(file);
}
catch (SftpPathNotFoundException)
{
// Absent is an answer rather than a failure. Every caller here is asking whether something is
// already there, and turning "no" into an exception would put a try/catch at each of them.
return null;
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
///
public Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);
///
public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);
///
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
{
try
{
await client.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
///
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
{
try
{
// One call for both kinds: SSH.NET stats the path and issues rmdir or remove accordingly. A
// non-empty directory fails here, which is the refusal this interface promises.
await client.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
///
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
try
{
await client.RenameFileAsync(fromPath, toPath, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
// Named for the destination, which is what the caller chose and what a collision is about. The
// source is a path the transfer queue made up.
throw Translate(toPath, exception);
}
}
///
public ValueTask DisposeAsync()
{
client.Dispose();
return ValueTask.CompletedTask;
}
///
/// Seeking after the open rather than asking for an appending mode. FileMode.Append would give the
/// right position for an upload and nothing for a download, and a resume has to be able to start at an
/// offset that is not the end — a part file whose tail was written by an interrupted transfer is
/// exactly that case, and the queue truncates to a known-good length before resuming.
///
private async Task OpenAsync(
string path,
FileMode mode,
FileAccess access,
long offset,
CancellationToken cancellationToken)
{
ArgumentOutOfRangeException.ThrowIfNegative(offset);
SftpFileStream stream;
try
{
stream = await client.OpenAsync(path, mode, access, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
if (offset == 0)
{
return stream;
}
try
{
stream.Seek(offset, SeekOrigin.Begin);
}
catch
{
await stream.DisposeAsync().ConfigureAwait(false);
throw;
}
return stream;
}
/// Directories first, then links, then everything else.
private static int Rank(SftpEntryKind kind) => kind switch
{
SftpEntryKind.Directory => 0,
SftpEntryKind.SymbolicLink => 1,
_ => 2,
};
private static SftpEntry Describe(ISftpFile file)
{
// Checked in this order because the predicates are not exclusive: a symbolic link to a directory
// answers true to both, and calling that a directory would hide the fact that opening it depends on
// the server resolving a link. Asking about the link first is the honest reading of an lstat.
var kind = file switch
{
{ IsSymbolicLink: true } => SftpEntryKind.SymbolicLink,
{ IsDirectory: true } => SftpEntryKind.Directory,
{ IsRegularFile: true } => SftpEntryKind.File,
_ => SftpEntryKind.Other,
};
return new SftpEntry(
file.Name,
file.FullName,
kind,
// Only a file's length means anything. A directory's is the size of its own inode, which is a
// number no user has ever wanted in a SIZE column.
kind is SftpEntryKind.File ? file.Length : 0,
new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero),
PosixMode.Format(
kind,
file.OwnerCanRead,
file.OwnerCanWrite,
file.OwnerCanExecute,
file.GroupCanRead,
file.GroupCanWrite,
file.GroupCanExecute,
file.OthersCanRead,
file.OthersCanWrite,
file.OthersCanExecute));
}
///
/// covers the SFTP-specific types as well, since both derive from it. It is
/// deliberately wide: the SFTP protocol returns a status code and a server-supplied message for
/// everything from a missing file to a full disk, and SSH.NET surfaces most of them as a bare
/// carrying that message. What must not be caught is cancellation and
/// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
///
private static bool IsPathFailure(Exception exception) =>
exception is SshException or IOException or UnauthorizedAccessException;
private static SftpPathException Translate(string path, Exception exception) => exception switch
{
SftpPathNotFoundException => new SftpPathException(path, $"{path} is not there.", exception),
SftpPermissionDeniedException => new SftpPathException(
path, $"The server refused access to {path}.", exception),
// The server's own words. They are the only description of a full disk, a quota or a read-only mount
// that this client could produce, and inventing a friendlier sentence would lose them.
_ => new SftpPathException(path, $"{path}: {exception.Message}", exception),
};
}