Public Access
ISshConnection and ISftpSession both carry Cipher now — the server-to-client algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is not an event that library raises — and TerminalWorkspace.GetSessionFacts hands that plus the host key's algorithm back per session, without ever handing over the connection itself. Nothing reads either yet; the status bar that will is the next commit.
259 lines
9.8 KiB
C#
259 lines
9.8 KiB
C#
using Renci.SshNet;
|
|
using Renci.SshNet.Common;
|
|
using Renci.SshNet.Sftp;
|
|
|
|
namespace DodoSSH.Client.Ssh;
|
|
|
|
/// <summary>An SSH.NET-backed file-transfer session.</summary>
|
|
/// <remarks>
|
|
/// Thin on purpose. Everything above this reasons about <see cref="SftpEntry"/> and <see cref="Stream"/>,
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
|
: ISftpSession
|
|
{
|
|
/// <inheritdoc />
|
|
public bool IsConnected => client.IsConnected;
|
|
|
|
/// <inheritdoc />
|
|
public HostKeyPresentation HostKey { get; } = hostKey;
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
|
|
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
|
|
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
|
|
/// </remarks>
|
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
|
|
|
/// <inheritdoc />
|
|
public string HomeDirectory { get; } = homeDirectory;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
var entries = new List<SftpEntry>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SftpEntry?> 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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
|
OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
|
OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
client.Dispose();
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Seeking after the open rather than asking for an appending mode. <c>FileMode.Append</c> 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 <em>not</em> 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.
|
|
/// </remarks>
|
|
private async Task<Stream> 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;
|
|
}
|
|
|
|
/// <summary>Directories first, then links, then everything else.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <see cref="SshException"/> 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
|
|
/// <see cref="SshException"/> carrying that message. What must <em>not</em> be caught is cancellation and
|
|
/// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
|
|
/// </remarks>
|
|
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),
|
|
};
|
|
}
|