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 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(); } } }