Public Access
- 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.
368 lines
12 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|