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), }; }