using DodoSSH.Client.Import;
namespace DodoSSH.Client.Import.Tests;
///
/// Reading real-world ssh_config shapes.
///
///
/// Every case here is something a person's actual file contains. The value of an importer is entirely in
/// whether it agrees with ssh about what a file means — an importer that is nearly right produces
/// bookmarks that nearly connect, which is worse than one that refused.
///
public sealed class SshConfigParserTests
{
[Fact]
public void APlainBlock_ResolvesItsFields()
{
var import = Read("""
Host prod-db
HostName db.internal
User deploy
Port 2222
""");
var host = import.Hosts.ShouldHaveSingleItem();
host.Alias.ShouldBe("prod-db");
host.Hostname.ShouldBe("db.internal");
host.Username.ShouldBe("deploy");
host.Port.ShouldBe(2222);
host.Address.ShouldBe("deploy@db.internal:2222");
}
///
/// OpenSSH's own default, and the reason Host db.internal on its own works at all.
///
[Fact]
public void ABlockWithNoHostName_DialsItsAlias()
{
var host = Read("Host db.internal").Hosts.ShouldHaveSingleItem();
host.Hostname.ShouldBe("db.internal");
host.Port.ShouldBe(22);
host.Username.ShouldBeNull();
}
[Fact]
public void OneLineNamingSeveralHosts_YieldsOnePerName()
{
var import = Read("""
Host web1 web2 web3
User deploy
""");
import.Hosts.Select(host => host.Alias).ShouldBe(["web1", "web2", "web3"]);
import.Hosts.ShouldAllBe(host => host.Username == "deploy");
}
///
/// The rule that is not the intuitive one. A Host * block supplies what nothing earlier set and
/// cannot override what it did — get this backwards and every imported host takes the wildcard's
/// username.
///
[Fact]
public void AWildcardBlock_SuppliesDefaultsAndDoesNotOverride()
{
var import = Read("""
Host prod-db
HostName db.internal
User deploy
Host *
User root
Port 2200
""");
var host = import.Hosts.ShouldHaveSingleItem();
host.Username.ShouldBe("deploy", "the earlier block set it first");
host.Port.ShouldBe(2200, "nothing earlier set a port");
}
[Fact]
public void AWildcardBlock_IsNotItselfImported()
{
var import = Read("""
Host *.internal
User deploy
Host prod-db
HostName db.internal
""");
import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
import.SkippedPatterns.ShouldContain("*.internal", StringComparer.Ordinal);
import.Warnings.ShouldContain(warning => warning.Contains("*.internal", StringComparison.Ordinal));
}
[Fact]
public void AWildcardBlock_AppliesToTheHostsItMatches()
{
var import = Read("""
Host *.internal
User deploy
Host db.internal
Host web.example.com
""");
Alias(import, "db.internal").Username.ShouldBe("deploy");
Alias(import, "web.example.com").Username.ShouldBeNull();
}
[Theory]
[InlineData("Host prod-db\n Port=2222")]
[InlineData("Host prod-db\n Port = 2222")]
[InlineData("Host prod-db\n\tPort\t2222")]
public void TheFormsOpenSshAccepts_AllParse(string text)
{
Read(text).Hosts.ShouldHaveSingleItem().Port.ShouldBe(2222);
}
///
/// A whole-line # only, which is OpenSSH's rule: a # partway through a line is part of the
/// value, not the start of a comment. Treating it as one would silently truncate any value containing a
/// hash.
///
[Fact]
public void CommentsAndBlankLines_AreIgnored()
{
var import = Read("""
# my hosts
Host prod-db
HostName db.internal
#Host commented-out
# HostName nowhere
""");
import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
import.Hosts.ShouldNotContain(host => host.Alias == "commented-out");
}
[Fact]
public void AQuotedValue_KeepsItsSpaces()
{
var host = Read("""
Host prod-db
IdentityFile "C:\keys\my key"
""").Hosts.ShouldHaveSingleItem();
host.IdentityFiles.ShouldHaveSingleItem().ShouldBe(@"C:\keys\my key");
}
///
/// The keyword that legitimately repeats — ssh tries each in turn — so it accumulates rather than
/// being reported as a duplicate.
///
[Fact]
public void SeveralIdentityFiles_AreAllKept()
{
var host = Read("""
Host prod-db
IdentityFile ~/.ssh/id_ed25519
IdentityFile ~/.ssh/id_rsa
""").Hosts.ShouldHaveSingleItem();
host.IdentityFiles.Count.ShouldBe(2);
host.Warnings.ShouldBeEmpty("repeating IdentityFile is not a mistake");
}
///
/// HostOptions is unique by name and cannot represent a repeat, which its own remarks call an M1
/// limitation the import path must surface rather than quietly resolve. This is that surfacing.
///
[Fact]
public void ARepeatedKeyword_KeepsTheFirstAndSaysSo()
{
var host = Read("""
Host prod-db
ServerAliveInterval 30
serveraliveinterval 60
""").Hosts.ShouldHaveSingleItem();
host.Options
.Single(option => string.Equals(option.Name, "ServerAliveInterval", StringComparison.Ordinal))
.Value.ShouldBe("30");
host.Warnings.ShouldContain(warning =>
warning.Contains("ServerAliveInterval", StringComparison.OrdinalIgnoreCase));
}
///
/// Whether a Match applies depends on what is being connected to, or on a command's output.
/// Neither is knowable from the file, so its directives are dropped — the failure to avoid is
/// attributing them to whichever block happened to come before, which is what a parser that only knows
/// about Host does.
///
[Fact]
public void AMatchBlock_IsIgnoredAndItsDirectivesDoNotLeak()
{
var import = Read("""
Host prod-db
HostName db.internal
Match host bastion
User root
Port 2200
""");
var host = import.Hosts.ShouldHaveSingleItem();
host.Username.ShouldBeNull("a Match block's directives belong to nobody");
host.Port.ShouldBe(22);
import.Warnings.ShouldContain(warning => warning.Contains("Match", StringComparison.Ordinal));
}
[Fact]
public void AnInclude_IsReadInPlace()
{
var import = SshConfigResolver.Resolve(SshConfigParser.Parse(
"""
Include conf.d/*.conf
Host *
User fallback
""",
_ =>
[
"""
Host prod-db
HostName db.internal
User deploy
""",
]));
var host = import.Hosts.ShouldHaveSingleItem();
host.Alias.ShouldBe("prod-db");
host.Username.ShouldBe("deploy", "the include comes before the wildcard block that follows it");
}
///
/// A file that includes itself would otherwise be read to the depth cap, importing the same hosts
/// sixteen times — which reads as a bug in the importer rather than in the config.
///
[Fact]
public void AnIncludeCycle_StopsAndSaysSo()
{
var import = SshConfigResolver.Resolve(SshConfigParser.Parse(
"Include loop.conf",
_ =>
[
"""
Include loop.conf
Host prod-db
""",
]));
import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
import.Warnings.ShouldContain(warning => warning.Contains("already being read", StringComparison.Ordinal));
}
[Fact]
public void CrlfAndAByteOrderMark_ParseTheSameAsPlainText()
{
var import = Read("\ufeffHost prod-db\r\n HostName db.internal\r\n Port 2222\r\n");
var host = import.Hosts.ShouldHaveSingleItem();
host.Alias.ShouldBe("prod-db");
host.Hostname.ShouldBe("db.internal");
host.Port.ShouldBe(2222);
}
[Fact]
public void AnUnusablePort_FallsBackToTwentyTwoAndSaysSo()
{
var host = Read("""
Host prod-db
Port not-a-number
""").Hosts.ShouldHaveSingleItem();
host.Port.ShouldBe(22);
host.Warnings.ShouldContain(warning => warning.Contains("not-a-number", StringComparison.Ordinal));
}
///
/// Recorded, and explicitly not honoured: the SSH layer has no jump hosts. A bastion topology that
/// imported and quietly did not route would be the worst of the three options.
///
[Fact]
public void ProxyJump_IsRecordedAsIntentAndFlagged()
{
var host = Read("""
Host prod-db
HostName db.internal
ProxyJump bastion
""").Hosts.ShouldHaveSingleItem();
host.ProxyJump.ShouldBe("bastion");
var secret = host.ToSecret();
secret.Options.ShouldContain(option => option.Name == "ProxyJump" && option.Value == "bastion");
secret.Notes.ShouldNotBeNull().ShouldContain("does not route");
}
[Fact]
public void ProxyCommand_IsDroppedRatherThanStoredAsIfItWorked()
{
var host = Read("""
Host prod-db
ProxyCommand nc %h %p
""").Hosts.ShouldHaveSingleItem();
host.Options.ShouldNotContain(option => option.Name == "ProxyCommand");
host.Warnings.ShouldContain(warning => warning.Contains("ProxyCommand", StringComparison.Ordinal));
}
///
/// A directive that maps onto a first-class field must not also land in Options, or a host has
/// two places recording its port and one of them will be forgotten on the next edit.
///
[Fact]
public void FirstClassFields_DoNotAlsoAppearAsDirectives()
{
var secret = Read("""
Host prod-db
HostName db.internal
User deploy
Port 2222
ServerAliveInterval 30
""").Hosts.ShouldHaveSingleItem().ToSecret();
secret.Options.Select(option => option.Name).ShouldBe(["ServerAliveInterval"]);
}
[Fact]
public void AnEmptyConfiguration_YieldsNothingAndDoesNotThrow()
{
var import = Read(" \n\n# only a comment\n");
import.Hosts.ShouldBeEmpty();
import.SkippedPatterns.ShouldBeEmpty();
}
private static SshConfigImport Read(string text) =>
SshConfigResolver.Resolve(SshConfigParser.Parse(text));
private static ImportedHost Alias(SshConfigImport import, string alias) =>
import.Hosts.Single(host => string.Equals(host.Alias, alias, StringComparison.Ordinal));
}