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; /// /// /// Null, and the event below is never raised: this fake breaks reads rather than connections — see /// — because what the queue has to get right is a transfer that stops /// halfway, which is the same shape whichever caused it. /// public string? TransportFailure => null; /// #pragma warning disable CS0067 // The event is never used public event EventHandler? ConnectionLost; #pragma warning restore CS0067 /// Modes this session was asked to set, in order, so preservation is observable. public List<(string Path, int Mode)> PermissionsSet { get; } = []; /// Timestamps this session was asked to set, in order. public List<(string Path, DateTimeOffset When)> TimesSet { get; } = []; /// Set to make both setters refuse, which is what a server that will not do SETSTAT looks like. public bool RefuseAttributes { get; set; } /// 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; } /// 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; } /// 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; } /// /// 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. /// public bool CanWriteFromAnOffset { get; set; } = true; /// Fails the next write after this many bytes, once, the way a dropped upload does. public int? FailWriteAfter { 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) { 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(new CommittingStream( failAfter, 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(int? failAfter, byte[] prefix, Action commit) : MemoryStream() { private bool committed; public override void Write(ReadOnlySpan 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 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(); } } }