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