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.
262 lines
7.9 KiB
C#
262 lines
7.9 KiB
C#
using DodoSSH.Client.Ssh;
|
|
|
|
namespace DodoSSH.Client.Transfer.Tests;
|
|
|
|
/// <summary>
|
|
/// A remote filesystem in a dictionary.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Here rather than a container because what this suite is about is the queue's behaviour when a transfer
|
|
/// goes wrong halfway — a read that dies after so many bytes, a destination that appears while a transfer is
|
|
/// queued — and neither can be asked of a real server on cue. That the real server behaves as this fake
|
|
/// pretends is established separately, against sshd, in <c>SftpSessionTests</c>.
|
|
/// </remarks>
|
|
internal sealed class FakeSftpSession : ISftpSession
|
|
{
|
|
private readonly Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
|
|
private readonly HashSet<string> directories = new(StringComparer.Ordinal) { "/", "/home/dodo" };
|
|
private readonly Lock gate = new();
|
|
|
|
/// <inheritdoc />
|
|
public bool IsConnected => true;
|
|
|
|
/// <inheritdoc />
|
|
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
|
|
|
/// <inheritdoc />
|
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
|
|
|
/// <inheritdoc />
|
|
public string HomeDirectory => "/home/dodo";
|
|
|
|
/// <summary>Throws once, this many bytes into the next read, and then stops doing so.</summary>
|
|
/// <remarks>
|
|
/// One-shot on purpose: every resume test is "it broke, then it did not", and a fake that kept failing
|
|
/// would need turning off at exactly the point the assertion is about.
|
|
/// </remarks>
|
|
public int? FailReadAfter { get; set; }
|
|
|
|
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
|
|
/// <remarks>
|
|
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
|
|
/// whether the second attempt started at the offset or at zero.
|
|
/// </remarks>
|
|
public List<long> ReadOffsets { get; } = [];
|
|
|
|
/// <summary>Puts a file on the fake host.</summary>
|
|
public void Seed(string path, byte[] content)
|
|
{
|
|
lock (gate)
|
|
{
|
|
files[path] = content;
|
|
directories.Add(SftpPath.Parent(path));
|
|
}
|
|
}
|
|
|
|
/// <summary>What is at a path, or null.</summary>
|
|
public byte[]? Read(string path)
|
|
{
|
|
lock (gate)
|
|
{
|
|
return files.GetValueOrDefault(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>Whether anything is at a path.</summary>
|
|
public bool Exists(string path)
|
|
{
|
|
lock (gate)
|
|
{
|
|
return files.ContainsKey(path) || directories.Contains(path);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (!directories.Contains(path))
|
|
{
|
|
throw new SftpPathException(path, $"{path} is not there.");
|
|
}
|
|
|
|
IReadOnlyList<SftpEntry> entries =
|
|
[
|
|
.. files
|
|
.Where(file => string.Equals(SftpPath.Parent(file.Key), path, StringComparison.Ordinal))
|
|
.Select(file => Describe(file.Key, file.Value.Length)),
|
|
];
|
|
|
|
return Task.FromResult(entries);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (files.TryGetValue(path, out var content))
|
|
{
|
|
return Task.FromResult<SftpEntry?>(Describe(path, content.Length));
|
|
}
|
|
|
|
return Task.FromResult<SftpEntry?>(
|
|
directories.Contains(path)
|
|
? new SftpEntry(
|
|
SftpPath.Name(path),
|
|
path,
|
|
SftpEntryKind.Directory,
|
|
0,
|
|
DateTimeOffset.UnixEpoch,
|
|
"drwxr-xr-x")
|
|
: null);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
ReadOffsets.Add(offset);
|
|
|
|
if (!files.TryGetValue(path, out var content))
|
|
{
|
|
throw new SftpPathException(path, $"{path} is not there.");
|
|
}
|
|
|
|
var failAfter = FailReadAfter;
|
|
FailReadAfter = null;
|
|
|
|
return Task.FromResult<Stream>(
|
|
new BrittleStream(content.AsSpan((int)offset).ToArray(), failAfter));
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
var existing = files.GetValueOrDefault(path, []);
|
|
|
|
return Task.FromResult<Stream>(new CommittingStream(
|
|
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
|
|
content =>
|
|
{
|
|
lock (gate)
|
|
{
|
|
files[path] = content;
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
directories.Add(path);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (!files.Remove(path) && !directories.Remove(path))
|
|
{
|
|
throw new SftpPathException(path, $"{path} is not there.");
|
|
}
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (!files.TryGetValue(fromPath, out var content))
|
|
{
|
|
throw new SftpPathException(fromPath, $"{fromPath} is not there.");
|
|
}
|
|
|
|
// SFTP's rename does not replace, and the queue's promise never to overwrite rests on it. A fake
|
|
// that clobbered would make the one test about that promise pass for the wrong reason.
|
|
if (files.ContainsKey(toPath))
|
|
{
|
|
throw new SftpPathException(toPath, $"{toPath} is already there.");
|
|
}
|
|
|
|
files.Remove(fromPath);
|
|
files[toPath] = content;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
private static SftpEntry Describe(string path, int length) => new(
|
|
SftpPath.Name(path),
|
|
path,
|
|
SftpEntryKind.File,
|
|
length,
|
|
DateTimeOffset.UnixEpoch,
|
|
"-rw-r--r--");
|
|
|
|
/// <summary>A read that dies partway through, the way a dropped connection does.</summary>
|
|
private sealed class BrittleStream(byte[] content, int? failAfter) : MemoryStream(content, writable: false)
|
|
{
|
|
public override int Read(Span<byte> buffer)
|
|
{
|
|
Guard();
|
|
|
|
return base.Read(buffer);
|
|
}
|
|
|
|
public override ValueTask<int> ReadAsync(
|
|
Memory<byte> buffer,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Guard();
|
|
|
|
return base.ReadAsync(buffer, cancellationToken);
|
|
}
|
|
|
|
private void Guard()
|
|
{
|
|
if (failAfter is { } limit && Position >= limit)
|
|
{
|
|
throw new IOException("The connection dropped.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>A write that lands on the fake host when it is disposed.</summary>
|
|
private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
|
|
{
|
|
private bool committed;
|
|
|
|
public override void Close()
|
|
{
|
|
if (!committed)
|
|
{
|
|
committed = true;
|
|
commit([.. prefix, .. ToArray()]);
|
|
}
|
|
|
|
base.Close();
|
|
}
|
|
}
|
|
}
|