Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,13 +426,18 @@ func marshal(c Config) *bytes.Buffer {
// Pattern is a pattern in a Host declaration. Patterns are read-only values;
// create a new one with NewPattern().
type Pattern struct {
str string // Its appearance in the file, not the value that gets compiled.
regex *regexp.Regexp
not bool // True if this is a negated match
str string // Its appearance in the file, not the value that gets compiled.
regex *regexp.Regexp
not bool // True if this is a negated match
hasWhitespace bool
}

// String prints the string representation of the pattern.
func (p Pattern) String() string {
if p.hasWhitespace {
return fmt.Sprintf("\"%s\"", p.str)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we do fmt.Sprintf("%q") here?

Copy link
Author

@mntnorv mntnorv Jan 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. String() must return a representation that would work in an actual SSH config file, but %q escapes characters like ", \n, \r, \t. And the pattern string:

  • cannot contain ", because of the logic in parseSSHArguments;
  • cannot contain \n, \r, because of how the lexer parses a config;
  • can contain \t (and probably other characters that %q would escape), but they must be left as-is, as they are not escaped in an SSH config.

}

return p.str
}

Expand Down Expand Up @@ -461,14 +466,23 @@ func NewPattern(s string) (*Pattern, error) {
if s == "" {
return nil, errors.New("ssh_config: empty pattern")
}

negated := false
if s[0] == '!' {
negated = true
s = s[1:]
}

hasWhitespace := false

var buf bytes.Buffer
buf.WriteByte('^')

for i := 0; i < len(s); i++ {
if s[i] == ' ' || s[i] == '\t' {
hasWhitespace = true
}

// A byte loop is correct because all metacharacters are ASCII.
switch b := s[i]; b {
case '*':
Expand All @@ -483,12 +497,14 @@ func NewPattern(s string) (*Pattern, error) {
buf.WriteByte(b)
}
}

buf.WriteByte('$')
r, err := regexp.Compile(buf.String())
if err != nil {
return nil, err
}
return &Pattern{str: s, regex: r, not: negated}, nil

return &Pattern{str: s, regex: r, not: negated, hasWhitespace: hasWhitespace}, nil
}

// Host describes a Host directive and the keywords that follow it.
Expand Down
42 changes: 42 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var files = []string{
"testdata/config1",
"testdata/config2",
"testdata/eol-comments",
"testdata/double-quoted-decode",
}

func TestDecode(t *testing.T) {
Expand Down Expand Up @@ -259,6 +260,47 @@ func TestGetEqsign(t *testing.T) {
}
}

func TestGetDoubleQuotes(t *testing.T) {
us := &UserSettings{
userConfigFinder: testConfigFinder("testdata/double-quotes"),
}

val := us.Get("*.pattern-in-quotes.example.com", "HostName")
if val != "pattern-in-quotes.example.com" {
t.Errorf("expected to find HostName pattern-in-quotes.example.com, got %q", val)
}

val = us.Get("host with spaces", "HostName")
if val != "spaces.example.com" {
t.Errorf("expected to find HostName spaces.example.com, got %q", val)
}

val = us.Get("multiple hosts 1", "HostName")
if val != "multiple.example.com" {
t.Errorf("expected to find HostName multiple.example.com, got %q", val)
}

val = us.Get("multiple hosts 2", "HostName")
if val != "multiple.example.com" {
t.Errorf("expected to find HostName multiple.example.com, got %q", val)
}

val = us.Get("edge-case-1.example.com", "HostName")
if val != "edge-cases.example.com" {
t.Errorf("expected to find HostName edge-cases.example.com, got %q", val)
}

val = us.Get("edge-case-2.example.com", "HostName")
if val != "edge-cases.example.com" {
t.Errorf("expected to find HostName edge-cases.example.com, got %q", val)
}

val = us.Get("edge-case-3.example.com", "HostName")
if val != "edge-cases.example.com" {
t.Errorf("expected to find HostName edge-cases.example.com, got %q", val)
}
}

var includeFile = []byte(`
# This host should not exist, so we can use it for test purposes / it won't
# interfere with any other configurations.
Expand Down
31 changes: 30 additions & 1 deletion parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (p *sshParser) parseKV() sshParserStateFn {
return nil
}
if strings.ToLower(key.val) == "host" {
strPatterns := strings.Split(val.val, " ")
strPatterns := parseSSHArguments(val.val)
patterns := make([]*Pattern, 0)
for i := range strPatterns {
if strPatterns[i] == "" {
Expand Down Expand Up @@ -177,6 +177,35 @@ func (p *sshParser) parseComment() sshParserStateFn {
return p.parseStart
}

func parseSSHArguments(value string) []string {
args := []string{}

arg := ""
quotedArg := false

for _, r := range value {
switch r {
case '"':
quotedArg = !quotedArg
case ' ', '\t':
if quotedArg {
arg += string(r)
} else if len(arg) > 0 {
args = append(args, arg)
arg = ""
}
default:
arg += string(r)
}
}

if len(arg) > 0 {
args = append(args, arg)
}

return args
}

func parseSSH(flow chan token, system bool, depth uint8) *Config {
// Ensure we consume tokens to completion even if parser exits early
defer func() {
Expand Down
5 changes: 5 additions & 0 deletions testdata/double-quoted-decode
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Host "host with spaces"
HostName spaces.example.com

Host h1 "multiple hosts 1" h2 "multiple hosts 2" h3
HostName multiple.example.com
11 changes: 11 additions & 0 deletions testdata/double-quotes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Host "*.pattern-in-quotes.example.com"
HostName pattern-in-quotes.example.com

Host "host with spaces"
HostName spaces.example.com

Host h1 "multiple hosts 1" h2 "multiple hosts 2" h3
HostName multiple.example.com

Host edge"-case-1.example.com" "edge-case-"2.example.com edge-"case"-3.example.com
HostName edge-cases.example.com