blob: a9578421fd648c159f51a1ef015562c2ff76d864 [file] [edit]
package ssh_config
import (
"strings"
"testing"
)
func TestMatchHostBasic(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-host"),
}
val := us.Get("dev.example.com", "Port")
if val != "2222" {
t.Errorf("expected Port=2222 for dev.example.com, got %q", val)
}
val = us.Get("dev.example.com", "User")
if val != "admin" {
t.Errorf("expected User=admin for dev.example.com, got %q", val)
}
val = us.Get("dev.example.com", "IdentityFile")
if val != "~/.ssh/prod_key" {
t.Errorf("expected IdentityFile=~/.ssh/prod_key, got %q", val)
}
}
func TestMatchHostNoMatch(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-host"),
systemConfigFinder: nullConfigFinder,
}
// "other.com" doesn't match *.example.com, should fall back to defaults
val := us.Get("other.com", "Port")
if val != "22" {
t.Errorf("expected default Port=22 for other.com, got %q", val)
}
val = us.Get("other.com", "User")
if val != "" {
t.Errorf("expected empty User for other.com, got %q", val)
}
}
func TestMatchHostNegation(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-host-negation"),
}
// dev.example.com matches *.example.com and is not excluded by
// !*.test.example.com, so the Match block applies.
val := us.Get("dev.example.com", "Port")
if val != "2222" {
t.Errorf("expected Port=2222 for dev.example.com, got %q", val)
}
val = us.Get("dev.example.com", "User")
if val != "prod" {
t.Errorf("expected User=prod for dev.example.com, got %q", val)
}
// dev.test.example.com matches !*.test.example.com negation, so the
// Match block should NOT apply. The Host block should match instead.
val = us.Get("dev.test.example.com", "Port")
if val != "22" {
t.Errorf("expected Port=22 for dev.test.example.com (negated), got %q", val)
}
val = us.Get("dev.test.example.com", "User")
if val != "default" {
t.Errorf("expected User=default for dev.test.example.com (negated), got %q", val)
}
}
func TestMatchAll(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-all"),
systemConfigFinder: nullConfigFinder,
}
// "special" matches the explicit Host block first
val := us.Get("special", "Port")
if val != "1111" {
t.Errorf("expected Port=1111 for special, got %q", val)
}
// "special" should also get User from the Match all block
val = us.Get("special", "User")
if val != "matchuser" {
t.Errorf("expected User=matchuser for special, got %q", val)
}
// An arbitrary host should match "Match all"
val = us.Get("anything.example.com", "Port")
if val != "4567" {
t.Errorf("expected Port=4567 for anything.example.com via Match all, got %q", val)
}
val = us.Get("anything.example.com", "User")
if val != "matchuser" {
t.Errorf("expected User=matchuser for anything.example.com via Match all, got %q", val)
}
}
func TestMatchMixed(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-mixed"),
systemConfigFinder: nullConfigFinder,
}
// "bastion" matches the explicit Host block
val := us.Get("bastion", "Port")
if val != "22" {
t.Errorf("expected Port=22 for bastion, got %q", val)
}
val = us.Get("bastion", "User")
if val != "root" {
t.Errorf("expected User=root for bastion, got %q", val)
}
// app.prod.example.com matches "Match Host *.prod.example.com" and
// also "Host *.example.com". First match wins for each key.
val = us.Get("app.prod.example.com", "Port")
if val != "2222" {
t.Errorf("expected Port=2222 for app.prod.example.com, got %q", val)
}
val = us.Get("app.prod.example.com", "User")
if val != "deploy" {
t.Errorf("expected User=deploy for app.prod.example.com, got %q", val)
}
// app.staging.example.com matches "Host *.example.com" (Port 80)
// first, then "Match Host *.staging.example.com" (Port 3333).
// SSH semantics: first match wins per key.
val = us.Get("app.staging.example.com", "Port")
if val != "80" {
t.Errorf("expected Port=80 for app.staging.example.com (Host block first), got %q", val)
}
// plain.example.com matches "Host *.example.com" only
val = us.Get("plain.example.com", "Port")
if val != "80" {
t.Errorf("expected Port=80 for plain.example.com, got %q", val)
}
val = us.Get("plain.example.com", "User")
if val != "webuser" {
t.Errorf("expected User=webuser for plain.example.com, got %q", val)
}
// unknown host matches only "Match all"
val = us.Get("unknown.host", "User")
if val != "fallback" {
t.Errorf("expected User=fallback for unknown.host via Match all, got %q", val)
}
}
func TestMatchMixedGetAll(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-mixed"),
systemConfigFinder: nullConfigFinder,
}
// app.prod.example.com should get both IdentityFiles from the Match Host
// block, plus the one from Match all.
vals := us.GetAll("app.prod.example.com", "IdentityFile")
want := []string{"~/.ssh/prod_key1", "~/.ssh/prod_key2", "~/.ssh/default_key"}
if len(vals) != len(want) {
t.Fatalf("GetAll IdentityFile for app.prod.example.com: got %d values %v, want %d values %v", len(vals), vals, len(want), want)
}
for i := range want {
if vals[i] != want[i] {
t.Errorf("GetAll IdentityFile[%d]: got %q, want %q", i, vals[i], want[i])
}
}
}
func TestMatchDirectiveInline(t *testing.T) {
tests := []struct {
name string
config string
alias string
key string
wantVal string
wantErr string
}{
{
name: "basic match host",
config: `Match Host *.example.com
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "match host no match",
config: `Match Host *.example.com
Port 2222`,
alias: "test.other.com",
key: "Port",
wantVal: "",
},
{
name: "match all",
config: `Match all
Port 9999`,
alias: "anything",
key: "Port",
wantVal: "9999",
},
{
name: "match host multiple patterns",
config: `Match Host *.example.com *.example.org
Port 2222`,
alias: "test.example.org",
key: "Port",
wantVal: "2222",
},
{
name: "match host with comment",
config: `Match Host *.example.com # Production servers
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "empty match should error",
config: `Match
Port 2222`,
wantErr: "Match directive requires",
},
{
name: "match host case insensitive",
config: `Match HOST *.example.com
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "match host mixed case",
config: `Match HoSt *.example.com
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "match all uppercase",
config: `Match ALL
Port 9999`,
alias: "anything",
key: "Port",
wantVal: "9999",
},
{
name: "match keyword itself case insensitive",
config: `MATCH Host *.example.com
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "match host extra spaces between patterns",
config: `Match Host *.example.com *.example.org
Port 2222`,
alias: "test.example.org",
key: "Port",
wantVal: "2222",
},
{
name: "match host trailing spaces",
config: "Match Host *.example.com \n Port 2222",
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "match host leading spaces on match line",
config: ` Match Host *.example.com
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "2222",
},
{
name: "host before match host, same pattern",
config: `Host *.example.com
Port 1111
Match Host *.example.com
Port 2222`,
alias: "test.example.com",
key: "Port",
wantVal: "1111", // Host block appears first, wins
},
{
name: "match host before host, same pattern",
config: `Match Host *.example.com
Port 2222
Host *.example.com
Port 1111`,
alias: "test.example.com",
key: "Port",
wantVal: "2222", // Match block appears first, wins
},
{
name: "match host provides key not in host",
config: `Host *.example.com
Port 1111
Match Host *.example.com
User admin`,
alias: "test.example.com",
key: "User",
wantVal: "admin", // Not set in Host, comes from Match
},
{
name: "match host negation excludes",
config: `Match Host *.example.com !staging.example.com
Port 2222`,
alias: "staging.example.com",
key: "Port",
wantVal: "",
},
{
name: "match host negation allows",
config: `Match Host *.example.com !staging.example.com
Port 2222`,
alias: "prod.example.com",
key: "Port",
wantVal: "2222",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := Decode(strings.NewReader(tt.config))
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
got, err := cfg.Get(tt.alias, tt.key)
if err != nil {
t.Fatalf("unexpected Get error: %v", err)
}
if got != tt.wantVal {
t.Errorf("Get(%q, %q) = %q, want %q", tt.alias, tt.key, got, tt.wantVal)
}
})
}
}
func TestMatchUnsupportedCriteria(t *testing.T) {
// Every Match criterion from the ssh_config manpage that we don't
// support, plus case variations and the special Exec case.
tests := []struct {
name string
config string
wantErr string
}{
// Exec gets its own error message because it's a security concern.
{
name: "exec lowercase",
config: "Match exec \"echo hello\"\n Port 22",
wantErr: "ssh_config: Match Exec is not supported",
},
{
name: "exec uppercase",
config: "Match EXEC \"echo hello\"\n Port 22",
wantErr: "ssh_config: Match Exec is not supported",
},
{
name: "exec mixed case",
config: "Match ExEc \"echo hello\"\n Port 22",
wantErr: "ssh_config: Match Exec is not supported",
},
{
name: "exec with complex command",
config: "Match Exec \"test -f /etc/ssh/flag\"\n Port 22",
wantErr: "ssh_config: Match Exec is not supported",
},
// All other unsupported criteria.
{
name: "user",
config: "Match User admin\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "user uppercase",
config: "Match USER admin\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "localuser",
config: "Match LocalUser kevin\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "localuser uppercase",
config: "Match LOCALUSER kevin\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "originalhost",
config: "Match OriginalHost *.example.com\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "originalhost uppercase",
config: "Match ORIGINALHOST *.example.com\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "canonical",
config: "Match canonical\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "final",
config: "Match final\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "tagged",
config: "Match Tagged mytag\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "localnetwork",
config: "Match LocalNetwork 192.168.1.0/24\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
{
name: "completely bogus criterion",
config: "Match Bogus value\n Port 22",
wantErr: "ssh_config: unsupported Match criterion",
},
// Match Host with no patterns after it.
{
name: "match host with no patterns",
config: "Match Host\n Port 22",
wantErr: "ssh_config: Match Host requires at least one pattern",
},
// Match Host followed by only whitespace.
{
name: "match host only whitespace after",
config: "Match Host \n Port 22",
wantErr: "ssh_config: Match Host requires at least one pattern",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Decode(strings.NewReader(tt.config))
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
func TestMatchDirectiveGetAll(t *testing.T) {
config := `Match Host *.prod.example.com
IdentityFile ~/.ssh/prod_key1
IdentityFile ~/.ssh/prod_key2
Match all
IdentityFile ~/.ssh/default_key`
cfg, err := Decode(strings.NewReader(config))
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
vals, err := cfg.GetAll("app.prod.example.com", "IdentityFile")
if err != nil {
t.Fatalf("unexpected GetAll error: %v", err)
}
want := []string{"~/.ssh/prod_key1", "~/.ssh/prod_key2", "~/.ssh/default_key"}
if len(vals) != len(want) {
t.Fatalf("GetAll returned %d values %v, want %d values %v", len(vals), vals, len(want), want)
}
for i := range want {
if vals[i] != want[i] {
t.Errorf("GetAll[%d] = %q, want %q", i, vals[i], want[i])
}
}
}
func TestMatchStringRoundTrip(t *testing.T) {
tests := []struct {
name string
config string
}{
{
name: "match host",
config: `Match Host *.example.com
Port 2222
`,
},
{
name: "match all",
config: `Match all
Port 4567
`,
},
{
name: "match host with comment",
config: `Match Host *.example.com # production
Port 2222
`,
},
{
name: "match host multiple patterns",
config: `Match Host *.example.com *.example.org
Port 2222
`,
},
{
name: "match ALL uppercase round-trip",
config: `Match ALL
Port 4567
`,
},
{
name: "match All mixed case round-trip",
config: `Match All
Port 4567
`,
},
{
name: "mixed host and match",
config: `Host bastion
Port 22
Match Host *.example.com
Port 2222
Match all
User fallback
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := Decode(strings.NewReader(tt.config))
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
got := cfg.String()
if got != tt.config {
t.Errorf("round-trip mismatch:\ngot:\n%s\nwant:\n%s", got, tt.config)
}
})
}
}
func TestMatchFileRoundTrip(t *testing.T) {
for _, filename := range []string{
"testdata/match-host",
"testdata/match-all",
"testdata/match-mixed",
"testdata/match-host-negation",
} {
data := loadFile(t, filename)
cfg, err := Decode(strings.NewReader(string(data)))
if err != nil {
t.Fatalf("%s: unexpected parse error: %v", filename, err)
}
got := cfg.String()
if got != string(data) {
t.Errorf("%s: round-trip mismatch:\ngot:\n%s\nwant:\n%s", filename, got, string(data))
}
}
}
// TestMatchExistingDirectiveFile tests that the existing testdata/match-directive
// file (which contains "Match all") now parses successfully.
func TestMatchExistingDirectiveFile(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/match-directive"),
}
val := us.Get("anyhost", "Port")
if val != "4567" {
t.Errorf("expected Port=4567 via Match all, got %q", val)
}
}