using System.Text; namespace DodoSSH.Client.Ssh.Tests; /// /// The SFTP subsystem, against a real sshd. /// /// /// /// Everything this suite is about is behaviour of a server rather than of this code: whether a listing /// carries the permission bits the design's PERMS column needs, whether opening at an offset really /// starts there, and whether a rename over a name that already exists fails rather than silently replacing — /// which the transfer queue's part-file scheme depends on. None of it can be established against a mock. /// /// /// One directory per test, named after it, because the container is shared with every other suite in the /// assembly and a test that cleaned up by emptying the home directory would take another test's fixture with /// it. The session is shared too, and that is a limit of the server rather than tidiness — see /// , which explains what opening one per test did to the rest of the /// assembly. /// /// [Collection(SshCollection.Name)] public sealed class SftpSessionTests(SshServerFixture fixture) { private static CancellationToken Token => TestContext.Current.CancellationToken; [Fact] public async Task AnOpenedSession_StartsInTheAccountsHomeDirectory() { var sftp = await fixture.SftpAsync(Token); // Absolute, because the server canonicalises it during the handshake. A relative answer would make // every path the browser builds relative too, and the breadcrumb trail meaningless. SftpPath.IsAbsolute(sftp.HomeDirectory).ShouldBeTrue(sftp.HomeDirectory); sftp.IsConnected.ShouldBeTrue(); } [Fact] public async Task AListing_CarriesKindSizeAndPermissions() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync(sftp, nameof(AListing_CarriesKindSizeAndPermissions)); var content = "the quick brown fox"u8.ToArray(); await WriteAsync(sftp, SftpPath.Combine(directory, "a-file"), content); await sftp.CreateDirectoryAsync(SftpPath.Combine(directory, "a-directory"), Token); var entries = await sftp.ListAsync(directory, Token); // Directories first: the order this interface promises, and what the file browser shows without // sorting again. entries.Select(entry => entry.Name).ShouldBe(["a-directory", "a-file"]); var file = entries[1]; file.Kind.ShouldBe(SftpEntryKind.File); file.Length.ShouldBe(content.Length); file.FullPath.ShouldBe(SftpPath.Combine(directory, "a-file")); // The one column nothing in this repository could render before. The exact bits depend on the // server's umask, so what is pinned is the shape and the kind character rather than the mode. file.Permissions.Length.ShouldBe(10); file.Permissions[0].ShouldBe('-'); file.Permissions.ShouldStartWith("-rw"); entries[0].Kind.ShouldBe(SftpEntryKind.Directory); entries[0].Permissions[0].ShouldBe('d'); } [Fact] public async Task AListing_DropsTheDotEntries() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync(sftp, nameof(AListing_DropsTheDotEntries)); // Empty, which is the case where "." and ".." are the whole listing — so a browser that showed them // would present an empty directory as one holding two things. var entries = await sftp.ListAsync(directory, Token); entries.ShouldBeEmpty(); } [Fact] public async Task ListingSomethingThatIsNotADirectory_FailsWithThePath() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync( sftp, nameof(ListingSomethingThatIsNotADirectory_FailsWithThePath)); var file = SftpPath.Combine(directory, "not-a-directory"); await WriteAsync(sftp, file, "x"u8.ToArray()); // The path is the whole point of the translation. SSH.NET's own exception for this carries the // server's message and nothing about which path was asked for, and the browser has to say which // row failed. var failure = await Should.ThrowAsync(async () => await sftp.ListAsync(file, Token)); failure.Path.ShouldBe(file); } [Fact] public async Task Stat_AnswersNullForSomethingThatIsNotThere() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync( sftp, nameof(Stat_AnswersNullForSomethingThatIsNotThere)); // Absent is an answer rather than a failure: the transfer queue asks this before every upload to // find out whether it would be overwriting something, and that question has a "no". (await sftp.StatAsync(SftpPath.Combine(directory, "nothing-here"), Token)).ShouldBeNull(); } [Fact] public async Task OpeningAtAnOffset_ReadsFromThere() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync(sftp, nameof(OpeningAtAnOffset_ReadsFromThere)); var path = SftpPath.Combine(directory, "resumable"); await WriteAsync(sftp, path, "0123456789"u8.ToArray()); // The whole of resume, in one call. If the server ignored the offset this would read the file from // the start and a resumed download would silently duplicate its first half. var stream = await sftp.OpenReadAsync(path, 4, Token); await using var scope = stream.ConfigureAwait(false); using var reader = new StreamReader(stream, Encoding.UTF8); (await reader.ReadToEndAsync(Token)).ShouldBe("456789"); } [Fact] public async Task WritingAtAnOffset_LeavesWhatWasAlreadyThere() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync(sftp, nameof(WritingAtAnOffset_LeavesWhatWasAlreadyThere)); var path = SftpPath.Combine(directory, "appended"); await WriteAsync(sftp, path, "0123"u8.ToArray()); var stream = await sftp.OpenWriteAsync(path, 4, Token); await using (stream.ConfigureAwait(false)) { await stream.WriteAsync("456789"u8.ToArray(), Token); await stream.FlushAsync(Token); } // The other half of resume: an upload that carried on from an offset must not have truncated the // bytes an earlier attempt already delivered. (await ReadAllAsync(sftp, path)).ShouldBe("0123456789"); } [Fact] public async Task Rename_RefusesToReplaceSomethingThatIsAlreadyThere() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync( sftp, nameof(Rename_RefusesToReplaceSomethingThatIsAlreadyThere)); var part = SftpPath.Combine(directory, "artefact.dodossh-part"); var destination = SftpPath.Combine(directory, "artefact"); await WriteAsync(sftp, part, "new"u8.ToArray()); await WriteAsync(sftp, destination, "old"u8.ToArray()); // The transfer queue's last step, and the assumption underneath its promise never to overwrite: it // checks the destination before starting, and this is what stops a file that appeared in the // meantime from being replaced anyway. SFTP's rename is specified not to clobber, and this is the // check that the server this project tests against actually behaves that way. await Should.ThrowAsync(async () => await sftp.RenameAsync(part, destination, Token)); (await ReadAllAsync(sftp, destination)).ShouldBe("old"); } [Fact] public async Task DeletingANonEmptyDirectory_Fails() { var sftp = await fixture.SftpAsync(Token); var directory = await MakeDirectoryAsync(sftp, nameof(DeletingANonEmptyDirectory_Fails)); await WriteAsync(sftp, SftpPath.Combine(directory, "occupant"), "x"u8.ToArray()); // Deliberate, and the reason this interface has no recursive delete: the one destructive operation // on the transfers screen must not be able to take a directory tree with it. await Should.ThrowAsync(async () => await sftp.DeleteAsync(directory, Token)); } [Fact] public async Task AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe() { var factory = new SshNetConnectionFactory(new InMemoryKnownHostStore()); // File transfer opens its own connection, so it makes its own first-contact decision. The failure // that matters is the one this asserts is *not* raised: a bare connection error would send the user // looking at the network for what is a fingerprint they have not approved. var refusal = await Should.ThrowAsync(async () => await factory.OpenSftpAsync(Request(), Token)); refusal.Presentation.Host.ShouldBe(fixture.Host); refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:"); } private SshConnectionRequest Request() => new( fixture.Host, fixture.Port, SshServerFixture.Username, new SshPasswordCredential(SshServerFixture.Password)); /// A directory of this test's own, under the account's home. private static async Task MakeDirectoryAsync(ISftpSession sftp, string name) { var path = SftpPath.Combine(sftp.HomeDirectory, $"sftp-{name}"); if (await sftp.StatAsync(path, Token) is not null) { // A previous run of the same test in a container that outlived it. Emptying it is enough: // nothing here creates nested directories. foreach (var entry in await sftp.ListAsync(path, Token)) { await sftp.DeleteAsync(entry.FullPath, Token); } return path; } await sftp.CreateDirectoryAsync(path, Token); return path; } private static async Task WriteAsync(ISftpSession sftp, string path, byte[] content) { var stream = await sftp.OpenWriteAsync(path, 0, Token); await using (stream.ConfigureAwait(false)) { await stream.WriteAsync(content, Token); await stream.FlushAsync(Token); } } private static async Task ReadAllAsync(ISftpSession sftp, string path) { var stream = await sftp.OpenReadAsync(path, 0, Token); await using var scope = stream.ConfigureAwait(false); using var reader = new StreamReader(stream, Encoding.UTF8); return await reader.ReadToEndAsync(Token); } }