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