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 : ISftpSession
{
private readonly SftpClient client;
private string? transportFailure;
private int announced;
internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
{
this.client = client;
HostKey = hostKey;
HomeDirectory = homeDirectory;
Cipher = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
client.ErrorOccurred += OnTransportError;
}
///
public bool IsConnected => client.IsConnected;
///
public string? TransportFailure => Volatile.Read(ref transportFailure);
///
public event EventHandler? ConnectionLost;
///
public HostKeyPresentation HostKey { get; }
///
///
/// Read at construction, the same way and for the same reason as SshNetConnection.Cipher: this type
/// is only ever built after SshNetConnectionFactory.OpenSftpAsync has awaited client.ConnectAsync,
/// so ConnectionInfo is already populated by the time there is a session to read it from. The coalesce
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
///
public string Cipher { get; }
///
public string HomeDirectory { get; }
///
/// Latches the failure and says so once.
///
///
///
/// No blocked read to release, unlike the shell's own handler: every SFTP call is a request and a reply,
/// so a connection that has gone is discovered by the next one throwing. What this adds is that somebody
/// finds out without making that next call — a file browser sits idle between operations, and
/// waiting for a click to reveal a host that went away half an hour ago is exactly the stale CONNECTED
/// this exists to remove.
///
///
/// Announced once. SSH.NET can raise several errors as one connection comes apart, and a screen told
/// three times would report the second and third against a session it had already replaced.
///
///
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
if (Interlocked.Exchange(ref announced, 1) == 0)
{
ConnectionLost?.Invoke(this, EventArgs.Empty);
}
}
///
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);
}
}
///
///
/// ◆ On a thread-pool thread, because SSH.NET offers no asynchronous SETSTAT.
/// ChangePermissions is a blocking round trip and every caller here is on the thread the
/// interface is drawn from — so calling it directly would freeze the window for a link's latency, which
/// on the connections this product exists for is the difference between instant and noticeably not.
/// Every other operation on this type has an async form and uses it.
///
public async Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
{
// Sanitised here rather than trusted from the caller, so that the promise the interface makes about
// the three bits it does not write holds at the last point before the wire.
var bits = (short)PosixMode.Sanitise(mode);
try
{
await Task.Run(() => client.ChangePermissions(path, bits), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
///
/// See ; the same blocking round trip, moved off the caller's thread for the same reason.
public async Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken)
{
var when = lastWriteTimeUtc.UtcDateTime;
try
{
await Task.Run(() => client.SetLastWriteTimeUtc(path, when), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
///
public ValueTask DisposeAsync()
{
// Before the client goes, and it also settles the flag: a disposal that trips SSH.NET's own error
// path must not reach a screen as a connection this client did not mean to lose.
Interlocked.Exchange(ref announced, 1);
client.ErrorOccurred -= OnTransportError;
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),
};
}