Files
DodoSSH/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs
T
jaap-jan 03c8e8589a Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive
  letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided
  where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the
  resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot
  be a file there is left on the host and reported.
- EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run
  it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on
  macOS, and refuses a .desktop launcher on Linux.
- A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so
  the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue
  starts those over.
- The SFTP host picker re-found its selection from the empty moment of every vault reload and landed
  on the first host once a minute; the chosen host is now remembered, and a typed password is only
  sent to the host it was typed for.
2026-10-02 13:16:30 +02:00

368 lines
12 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 />
/// <remarks>
/// Null, and the event below is never raised: this fake breaks reads rather than connections — see
/// <see cref="FailReadAfter"/> — because what the queue has to get right is a transfer that stops
/// halfway, which is the same shape whichever caused it.
/// </remarks>
public string? TransportFailure => null;
/// <inheritdoc />
#pragma warning disable CS0067 // The event is never used
public event EventHandler? ConnectionLost;
#pragma warning restore CS0067
/// <summary>Modes this session was asked to set, in order, so preservation is observable.</summary>
public List<(string Path, int Mode)> PermissionsSet { get; } = [];
/// <summary>Timestamps this session was asked to set, in order.</summary>
public List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
/// <summary>Set to make both setters refuse, which is what a server that will not do SETSTAT looks like.</summary>
public bool RefuseAttributes { get; set; }
/// <inheritdoc />
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
{
if (RefuseAttributes)
{
throw new SftpPathException(path, "This server will not set a mode.");
}
lock (gate)
{
PermissionsSet.Add((path, mode));
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken)
{
if (RefuseAttributes)
{
throw new SftpPathException(path, "This server will not set a timestamp.");
}
lock (gate)
{
TimesSet.Add((path, lastWriteTimeUtc));
}
return Task.CompletedTask;
}
/// <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>
/// Whether a write may start anywhere but zero. False makes this behave like a bucket, which refuses an
/// offset — and, like a bucket, still keeps whatever an abandoned write had sent when it is closed.
/// </summary>
public bool CanWriteFromAnOffset { get; set; } = true;
/// <summary>Fails the next write after this many bytes, once, the way a dropped upload does.</summary>
public int? FailWriteAfter { 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)
{
if (!CanWriteFromAnOffset && offset != 0)
{
throw new SftpPathException(path, "An object cannot be written to from the middle.");
}
lock (gate)
{
var existing = files.GetValueOrDefault(path, []);
var failAfter = FailWriteAfter;
FailWriteAfter = null;
return Task.FromResult<Stream>(new CommittingStream(
failAfter,
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(int? failAfter, byte[] prefix, Action<byte[]> commit) : MemoryStream()
{
private bool committed;
public override void Write(ReadOnlySpan<byte> buffer)
{
FailIfDue(buffer.Length);
base.Write(buffer);
}
public override void Write(byte[] buffer, int offset, int count)
{
FailIfDue(count);
base.Write(buffer, offset, count);
}
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
FailIfDue(buffer.Length);
return base.WriteAsync(buffer, cancellationToken);
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
FailIfDue(count);
return base.WriteAsync(buffer, offset, count, cancellationToken);
}
private void FailIfDue(int count)
{
if (failAfter is { } limit && Length + count > limit)
{
throw new IOException("The connection dropped mid-upload.");
}
}
public override void Close()
{
if (!committed)
{
committed = true;
commit([.. prefix, .. ToArray()]);
}
base.Close();
}
}
}