using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Transfer.Tests;
///
/// A remote filesystem in a dictionary.
///
///
/// 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 SftpSessionTests.
///
internal sealed class FakeSftpSession : ISftpSession
{
private readonly Dictionary files = new(StringComparer.Ordinal);
private readonly HashSet directories = new(StringComparer.Ordinal) { "/", "/home/dodo" };
private readonly Lock gate = new();
///
public bool IsConnected => true;
///
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
///
public string Cipher { get; } = "aes256-gcm@openssh.com";
///
public string HomeDirectory => "/home/dodo";
/// Throws once, this many bytes into the next read, and then stops doing so.
///
/// 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.
///
public int? FailReadAfter { get; set; }
/// The offsets was asked to start at, in order.
///
/// 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.
///
public List ReadOffsets { get; } = [];
/// Puts a file on the fake host.
public void Seed(string path, byte[] content)
{
lock (gate)
{
files[path] = content;
directories.Add(SftpPath.Parent(path));
}
}
/// What is at a path, or null.
public byte[]? Read(string path)
{
lock (gate)
{
return files.GetValueOrDefault(path);
}
}
/// Whether anything is at a path.
public bool Exists(string path)
{
lock (gate)
{
return files.ContainsKey(path) || directories.Contains(path);
}
}
///
public Task> ListAsync(string path, CancellationToken cancellationToken)
{
lock (gate)
{
if (!directories.Contains(path))
{
throw new SftpPathException(path, $"{path} is not there.");
}
IReadOnlyList 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);
}
}
///
public Task StatAsync(string path, CancellationToken cancellationToken)
{
lock (gate)
{
if (files.TryGetValue(path, out var content))
{
return Task.FromResult(Describe(path, content.Length));
}
return Task.FromResult(
directories.Contains(path)
? new SftpEntry(
SftpPath.Name(path),
path,
SftpEntryKind.Directory,
0,
DateTimeOffset.UnixEpoch,
"drwxr-xr-x")
: null);
}
}
///
public Task 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(
new BrittleStream(content.AsSpan((int)offset).ToArray(), failAfter));
}
}
///
public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
lock (gate)
{
var existing = files.GetValueOrDefault(path, []);
return Task.FromResult(new CommittingStream(
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
content =>
{
lock (gate)
{
files[path] = content;
}
}));
}
}
///
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
{
lock (gate)
{
directories.Add(path);
}
return Task.CompletedTask;
}
///
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;
}
///
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;
}
///
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--");
/// A read that dies partway through, the way a dropped connection does.
private sealed class BrittleStream(byte[] content, int? failAfter) : MemoryStream(content, writable: false)
{
public override int Read(Span buffer)
{
Guard();
return base.Read(buffer);
}
public override ValueTask ReadAsync(
Memory 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.");
}
}
}
/// A write that lands on the fake host when it is disposed.
private sealed class CommittingStream(byte[] prefix, Action commit) : MemoryStream()
{
private bool committed;
public override void Close()
{
if (!committed)
{
committed = true;
commit([.. prefix, .. ToArray()]);
}
base.Close();
}
}
}