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