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.
442 lines
20 KiB
C#
442 lines
20 KiB
C#
using System.Globalization;
|
|
|
|
namespace DodoSSH.Client.Ssh;
|
|
|
|
/// <summary>What kind of thing a remote directory entry is.</summary>
|
|
/// <remarks>
|
|
/// Taken from the attributes a listing already carries, which are <c>lstat</c> attributes: a symbolic link
|
|
/// reports as a link whatever it points at. Resolving each one would be a round trip per entry, so
|
|
/// <see cref="SftpEntryKind.SymbolicLink"/> stays its own answer and the caller finds out what it leads to by
|
|
/// trying to list it — see <see cref="ISftpSession.ListAsync"/>.
|
|
/// </remarks>
|
|
public enum SftpEntryKind
|
|
{
|
|
/// <summary>An ordinary file.</summary>
|
|
File = 0,
|
|
|
|
/// <summary>A directory.</summary>
|
|
Directory = 1,
|
|
|
|
/// <summary>A symbolic link, to something this listing did not resolve.</summary>
|
|
SymbolicLink = 2,
|
|
|
|
/// <summary>A socket, device, pipe or anything else that is not one of the three above.</summary>
|
|
Other = 3,
|
|
}
|
|
|
|
/// <summary>One entry in a remote directory.</summary>
|
|
/// <param name="Name">The entry's own name, with no path.</param>
|
|
/// <param name="FullPath">The absolute path, which is what every operation takes.</param>
|
|
/// <param name="Kind">What it is.</param>
|
|
/// <param name="Length">Size in bytes. Meaningless for anything that is not a file, and zero there.</param>
|
|
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
|
/// <param name="Permissions">The mode as <c>drwxr-xr-x</c>; see <see cref="PosixMode"/>.</param>
|
|
/// <remarks>
|
|
/// A record of what the server said rather than a handle. Nothing here holds a channel open, so a listing can
|
|
/// be kept on a screen after the session behind it has gone — which is what the transfers screen does while a
|
|
/// connection is being re-established.
|
|
/// </remarks>
|
|
public sealed record SftpEntry(
|
|
string Name,
|
|
string FullPath,
|
|
SftpEntryKind Kind,
|
|
long Length,
|
|
DateTimeOffset LastWriteTimeUtc,
|
|
string Permissions)
|
|
{
|
|
/// <summary>Whether this is somewhere the file browser can navigate into.</summary>
|
|
/// <remarks>
|
|
/// True for a symbolic link as well as a directory, because a link to a directory is the ordinary way a
|
|
/// remote filesystem is laid out and refusing to open one would make those paths unreachable. A link to a
|
|
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
|
|
/// </remarks>
|
|
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
|
|
|
|
/// <summary>Whether this is a file somebody can run.</summary>
|
|
/// <remarks>
|
|
/// Files only. On a directory the execute bit means "may be searched", which is true of very nearly every
|
|
/// directory on a host — a listing that marked them all would be marking nothing.
|
|
/// </remarks>
|
|
public bool IsExecutable => Kind is SftpEntryKind.File && PosixMode.HasAnyExecuteBit(Permissions);
|
|
|
|
/// <summary>
|
|
/// Whether this is a file any account on the host may write to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Files only, and for two separate reasons. A symbolic link is <c>lrwxrwxrwx</c> by convention on every
|
|
/// system that has one, and its mode governs nothing: what may be written is the target, whose own mode
|
|
/// this listing did not fetch. And a directory that everyone may write to is the ordinary arrangement for
|
|
/// <c>/tmp</c>, made safe by the sticky bit — which <see cref="PosixMode"/> does not render, so flagging
|
|
/// the directory would be warning about the half of the mode that is on screen while the half that
|
|
/// answers the warning is not.
|
|
/// </para>
|
|
/// <para>
|
|
/// It is not a claim that writing is dangerous, only that the mode says something a reader of that column
|
|
/// would want to have noticed.
|
|
/// </para>
|
|
/// </remarks>
|
|
public bool IsWorldWritable => Kind is SftpEntryKind.File && PosixMode.IsWorldWritable(Permissions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders POSIX permission bits the way <c>ls -l</c> does.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Nothing in this repository formatted a mode before, and the design's file listing has a <c>PERMS</c>
|
|
/// column. It is written from the individual bits rather than from an octal mode because that is the shape
|
|
/// SFTP hands over — <c>ISftpFile</c> exposes nine booleans and a set of kind predicates, and reassembling
|
|
/// them into an octal number only to take it apart again would be a round trip through a representation
|
|
/// neither end uses.
|
|
/// <para>
|
|
/// The setuid, setgid and sticky bits are not shown. SFTP's own file attributes carry them, SSH.NET does not
|
|
/// surface them on <c>ISftpFile</c>, and a column that showed <c>rwx</c> where <c>rws</c> was true would be
|
|
/// worse than one that never claims to render them.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class PosixMode
|
|
{
|
|
/// <summary>Formats one entry's mode, kind character included.</summary>
|
|
public static string Format(
|
|
SftpEntryKind kind,
|
|
bool ownerRead,
|
|
bool ownerWrite,
|
|
bool ownerExecute,
|
|
bool groupRead,
|
|
bool groupWrite,
|
|
bool groupExecute,
|
|
bool othersRead,
|
|
bool othersWrite,
|
|
bool othersExecute)
|
|
{
|
|
Span<char> mode = stackalloc char[10];
|
|
|
|
mode[0] = kind switch
|
|
{
|
|
SftpEntryKind.Directory => 'd',
|
|
SftpEntryKind.SymbolicLink => 'l',
|
|
SftpEntryKind.File => '-',
|
|
_ => '?',
|
|
};
|
|
|
|
Write(mode[1..4], ownerRead, ownerWrite, ownerExecute);
|
|
Write(mode[4..7], groupRead, groupWrite, groupExecute);
|
|
Write(mode[7..10], othersRead, othersWrite, othersExecute);
|
|
|
|
return new string(mode);
|
|
|
|
static void Write(Span<char> triple, bool read, bool write, bool execute)
|
|
{
|
|
triple[0] = read ? 'r' : '-';
|
|
triple[1] = write ? 'w' : '-';
|
|
triple[2] = execute ? 'x' : '-';
|
|
}
|
|
}
|
|
|
|
/// <summary>Whether any of the three execute bits is set.</summary>
|
|
public static bool HasAnyExecuteBit(string mode) =>
|
|
At(mode, OwnerExecute) == 'x' || At(mode, GroupExecute) == 'x' || At(mode, OthersExecute) == 'x';
|
|
|
|
/// <summary>Whether the others triple carries the write bit.</summary>
|
|
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
|
|
|
|
private const int OwnerExecute = 3;
|
|
private const int GroupExecute = 6;
|
|
private const int OthersWrite = 8;
|
|
private const int OthersExecute = 9;
|
|
|
|
/// <remarks>
|
|
/// Reading back what <see cref="Format"/> wrote, rather than carrying the nine booleans through
|
|
/// <see cref="SftpEntry"/> as well. The alternative is a second representation of one fact, and the two
|
|
/// disagreeing is the failure this avoids — a row coloured for a bit the column beside it does not show.
|
|
/// Anything that is not a mode this type wrote answers false rather than throwing: these questions decide
|
|
/// a colour, and a listing is not worth failing over one.
|
|
/// </remarks>
|
|
private static char At(string mode, int index) => mode.Length == 10 ? mode[index] : '-';
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remote paths, which are POSIX paths whatever this client is running on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Not <see cref="Path"/>.</b> The BCL's path helpers use the local platform's separator, so on Windows
|
|
/// <c>Path.Combine("/var", "log")</c> yields <c>/var\log</c> — a path the remote will not resolve and which
|
|
/// fails as "no such file" somewhere the user cannot see the backslash. Every remote path in this codebase
|
|
/// goes through here.
|
|
/// </remarks>
|
|
public static class SftpPath
|
|
{
|
|
/// <summary>The root of a remote filesystem.</summary>
|
|
public const string Root = "/";
|
|
|
|
/// <summary>Joins a directory and a name.</summary>
|
|
public static string Combine(string directory, string name)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(directory);
|
|
|
|
return directory.EndsWith('/') ? directory + name : directory + "/" + name;
|
|
}
|
|
|
|
/// <summary>The directory holding a path, or the path itself when it is already the root.</summary>
|
|
public static string Parent(string path)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
|
|
|
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
|
var slash = trimmed.LastIndexOf('/');
|
|
|
|
return slash switch
|
|
{
|
|
< 0 => Root,
|
|
0 => Root,
|
|
_ => trimmed[..slash],
|
|
};
|
|
}
|
|
|
|
/// <summary>The last segment of a path, or the root when that is all there is.</summary>
|
|
/// <remarks>
|
|
/// The root names itself. Taking the text after its only separator leaves an empty string, which as a
|
|
/// heading or a tab label is a blank where a path should be.
|
|
/// </remarks>
|
|
public static string Name(string path)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
|
|
|
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
|
var slash = trimmed.LastIndexOf('/');
|
|
var name = slash < 0 ? trimmed : trimmed[(slash + 1)..];
|
|
|
|
return name.Length == 0 ? Root : name;
|
|
}
|
|
|
|
/// <summary>Whether a path is already anchored at the root.</summary>
|
|
public static bool IsAbsolute(string path) => path.StartsWith('/');
|
|
|
|
/// <summary>
|
|
/// The segments of a path, for a breadcrumb trail.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Each segment paired with the absolute path that reaches it, root first. An empty list for the root
|
|
/// itself, which has no segment to name.
|
|
/// </returns>
|
|
public static IReadOnlyList<(string Name, string Path)> Trail(string path)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
|
|
|
var trail = new List<(string, string)>();
|
|
var walked = string.Empty;
|
|
|
|
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
walked = Combine(walked.Length == 0 ? Root : walked, segment);
|
|
trail.Add((segment, walked));
|
|
}
|
|
|
|
return trail;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A file-transfer session on one host.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>This is a connection, not a channel.</b> The obvious shape would have been
|
|
/// <c>ISshConnection.OpenSftpAsync</c>, opening the SFTP subsystem beside the shell on the transport that is
|
|
/// already up — which is what SSH itself allows and what <c>docs/design-import-gaps.md</c> assumed it would
|
|
/// take. SSH.NET does not offer it: <c>SftpClient</c> derives from <c>BaseClient</c> and owns its own
|
|
/// transport, and there is no supported way to hand it an existing <c>SshClient</c>'s session. So opening one
|
|
/// of these authenticates again.
|
|
/// </para>
|
|
/// <para>
|
|
/// It is named for that rather than dressed up as a channel, because the difference is visible to a user: the
|
|
/// host sees a second login, a one-time password would be asked for twice, and closing every terminal on a
|
|
/// host does not close its file browser. <see cref="ISshConnectionFactory.OpenSftpAsync"/> is a
|
|
/// <em>connect</em>, and host key trust is checked on it exactly as it is for a shell.
|
|
/// </para>
|
|
/// <para>
|
|
/// One session is one channel, and everything on it shares that channel's window — so the queue that drives
|
|
/// this runs one transfer at a time. That is a throughput decision rather than a safety one: two large
|
|
/// transfers over one channel do not go faster than one, they arrive later and both at once. Browsing while a
|
|
/// transfer runs is fine, and is the point of not opening a session per transfer.
|
|
/// </para>
|
|
/// </remarks>
|
|
public interface ISftpSession : IRemoteFileStore
|
|
{
|
|
/// <summary>The host key that was accepted for this session.</summary>
|
|
HostKeyPresentation HostKey { get; }
|
|
|
|
/// <summary>
|
|
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same fact <see cref="ISshConnection.Cipher"/> is, read the same way — off SSH.NET's
|
|
/// <c>ConnectionInfo.CurrentServerEncryption</c> once the handshake this session's own connect performed
|
|
/// has finished — and for the same reason: <c>SftpClient</c> derives from <c>BaseClient</c> exactly as
|
|
/// <c>SshClient</c> does, and rekeys are no more visible here than they are there. See that member's remark.
|
|
/// </remarks>
|
|
string Cipher { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A remote place with files in it, whatever protocol reaches it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Extracted from <see cref="ISftpSession"/> when buckets arrived, and unchanged in shape — the transfer
|
|
/// queue reads, writes, stats and lists, and never once needed anything SSH-specific. What stayed behind on
|
|
/// <c>ISftpSession</c> is the one member that could not be answered by a bucket: a host key.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It lives in a project called <c>.Ssh</c>, which is a naming debt worth writing down rather than
|
|
/// paying.</b> <see cref="SftpEntry"/> is here too and is the type every listing is made of, so moving the
|
|
/// interface without moving that would split the vocabulary in half — and moving both means renaming a
|
|
/// record that the whole file browser and its tests are written against. The cost of leaving it is a
|
|
/// reference that reads oddly from the object-store project; the cost of moving it is a rename with no
|
|
/// behaviour in it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Not every implementation can do everything, and the contract says which.</b> An object store has no
|
|
/// directories, no rename and no way to resume a half-finished upload; each of those is documented on the
|
|
/// member and refused with a reason rather than silently approximated. See <c>S3FileStore</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public interface IRemoteFileStore : IAsyncDisposable
|
|
{
|
|
/// <summary>Whether the transport is still up.</summary>
|
|
bool IsConnected { get; }
|
|
|
|
/// <summary>
|
|
/// Where the session starts, which is the account's home directory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Absolute, because the server resolves it during the handshake. It is the only path this layer knows
|
|
/// without asking, and it is what a file browser should open on.
|
|
/// </remarks>
|
|
string HomeDirectory { get; }
|
|
|
|
/// <summary>
|
|
/// Lists a directory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>.</c> and <c>..</c> are dropped: every remote directory has them, no user is choosing between them,
|
|
/// and the way up is a breadcrumb rather than a row. Ordered directories first and then by name, which is
|
|
/// what a file browser has to show and what saves every caller sorting it again.
|
|
/// </remarks>
|
|
/// <exception cref="SftpPathException">The path is not a directory, or is not readable.</exception>
|
|
Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken);
|
|
|
|
/// <summary>What one path is, or null when nothing is there.</summary>
|
|
Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Opens a remote file for reading, starting at an offset.
|
|
/// </summary>
|
|
/// <param name="path">The file.</param>
|
|
/// <param name="offset">Where to start, which is what makes an interrupted download resumable.</param>
|
|
/// <param name="cancellationToken">Abandons the open.</param>
|
|
Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Opens a remote file for writing, starting at an offset.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Creates the file when it is not there, and does not truncate one that is — an offset of zero over an
|
|
/// existing file overwrites from the beginning and leaves any tail beyond what is written. Callers are
|
|
/// expected to write to a path nothing else holds; the transfer queue writes to a part file for exactly
|
|
/// this reason.
|
|
/// </remarks>
|
|
/// <param name="path">The file.</param>
|
|
/// <param name="offset">Where to start, which is what makes an interrupted upload resumable.</param>
|
|
/// <param name="cancellationToken">Abandons the open.</param>
|
|
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Creates one directory, whose parent must exist.</summary>
|
|
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Deletes a file, or an empty directory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately not recursive. A recursive remote delete is the one operation on this screen that can
|
|
/// destroy something no undo reaches, and offering it behind the same button as deleting one file is how
|
|
/// that happens by accident. A non-empty directory fails, and says so.
|
|
/// </remarks>
|
|
Task DeleteAsync(string path, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Renames or moves a path within the same host.</summary>
|
|
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Something on the remote filesystem could not be reached, and the server said why.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One exception for the whole surface rather than one per operation, because there is exactly one thing a
|
|
/// caller does with any of them: show the message beside the path it was about. SSH.NET raises several
|
|
/// unrelated types for what a user experiences as one condition — <c>SftpPathNotFoundException</c>,
|
|
/// <c>SftpPermissionDeniedException</c>, and a bare <c>SshException</c> for the rest — and the path is not on
|
|
/// all of them.
|
|
/// </remarks>
|
|
public sealed class SftpPathException(string path, string message, Exception? innerException = null)
|
|
: Exception(message, innerException)
|
|
{
|
|
/// <summary>The path the failure was about.</summary>
|
|
public string Path { get; } = path;
|
|
}
|
|
|
|
/// <summary>Opens file-transfer sessions.</summary>
|
|
/// <remarks>
|
|
/// Declared beside <see cref="ISshConnectionFactory"/> and implemented by the same type, because both start
|
|
/// with the same handshake and the same host key decision. See <see cref="ISftpSession"/> for why this is a
|
|
/// separate connect rather than a channel on a connection that already exists.
|
|
/// </remarks>
|
|
public interface ISftpSessionFactory
|
|
{
|
|
/// <summary>
|
|
/// Connects, authenticates, and starts the SFTP subsystem.
|
|
/// </summary>
|
|
/// <exception cref="SshHostKeyUnknownException">
|
|
/// The host has no pinned key. Resolved exactly as it is for a shell: show the fingerprint, record it on
|
|
/// explicit confirmation, and retry.
|
|
/// </exception>
|
|
/// <exception cref="SshHostKeyMismatchException">
|
|
/// The presented key differs from the pin. There is no retry path.
|
|
/// </exception>
|
|
Task<ISftpSession> OpenSftpAsync(SshConnectionRequest request, CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>Formats a byte count the way a file browser shows one.</summary>
|
|
/// <remarks>
|
|
/// Here rather than in the view model because the transfer queue's own progress reporting needs the same
|
|
/// wording, and two spellings of "1.4 MB" in one window reads as two different measurements.
|
|
/// </remarks>
|
|
public static class ByteSize
|
|
{
|
|
private static readonly string[] Units = ["B", "KB", "MB", "GB", "TB"];
|
|
|
|
/// <summary>Formats a byte count to three significant figures.</summary>
|
|
public static string Format(long bytes)
|
|
{
|
|
if (bytes < 1024)
|
|
{
|
|
return string.Create(CultureInfo.InvariantCulture, $"{bytes} B");
|
|
}
|
|
|
|
double scaled = bytes;
|
|
var unit = 0;
|
|
|
|
while (scaled >= 1024 && unit < Units.Length - 1)
|
|
{
|
|
scaled /= 1024;
|
|
unit++;
|
|
}
|
|
|
|
// One decimal below ten, none above: "9.4 MB" and "512 MB" are both three characters of information,
|
|
// and "512.3 MB" is a precision the number does not have by the time it is that large.
|
|
return scaled < 10
|
|
? string.Create(CultureInfo.InvariantCulture, $"{scaled:0.0} {Units[unit]}")
|
|
: string.Create(CultureInfo.InvariantCulture, $"{scaled:0} {Units[unit]}");
|
|
}
|
|
}
|