Files
DodoSSH/src/DodoSSH.Client.Ssh/SftpSession.cs
T
jaap-jan 8209f15741 Let a session's transport say what it negotiated
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.
2026-08-08 20:54:14 +02:00

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]}");
}
}