Merge pull request #2230 from alissonbrunosa/fix-2179

Add support for ssh_config Include directives
This commit is contained in:
Mislav Marohnić 2020-12-15 16:11:27 +01:00 committed by GitHub
commit ae68da6520
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 231 additions and 67 deletions

View file

@ -1,6 +1,17 @@
package git
import "testing"
import (
"reflect"
"testing"
)
// TODO: extract assertion helpers into a shared package
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
func Test_parseRemotes(t *testing.T) {
remoteList := []string{

View file

@ -13,15 +13,10 @@ import (
)
var (
sshHostRE,
sshTokenRE *regexp.Regexp
sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
sshTokenRE = regexp.MustCompile(`%[%h]`)
)
func init() {
sshHostRE = regexp.MustCompile("(?i)^[ \t]*(host|hostname)[ \t]+(.+)$")
sshTokenRE = regexp.MustCompile(`%[%h]`)
}
// SSHAliasMap encapsulates the translation of SSH hostname aliases
type SSHAliasMap map[string]string
@ -45,6 +40,103 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
}
}
type sshParser struct {
homeDir string
aliasMap SSHAliasMap
hosts []string
open func(string) (io.Reader, error)
glob func(string) ([]string, error)
}
func (p *sshParser) read(fileName string) error {
var file io.Reader
if p.open == nil {
f, err := os.Open(fileName)
if err != nil {
return err
}
defer f.Close()
file = f
} else {
var err error
file, err = p.open(fileName)
if err != nil {
return err
}
}
if len(p.hosts) == 0 {
p.hosts = []string{"*"}
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
m := sshConfigLineRE.FindStringSubmatch(scanner.Text())
if len(m) < 3 {
continue
}
keyword, arguments := strings.ToLower(m[1]), m[2]
switch keyword {
case "host":
p.hosts = strings.Fields(arguments)
case "hostname":
for _, host := range p.hosts {
for _, name := range strings.Fields(arguments) {
if p.aliasMap == nil {
p.aliasMap = make(SSHAliasMap)
}
p.aliasMap[host] = sshExpandTokens(name, host)
}
}
case "include":
for _, arg := range strings.Fields(arguments) {
path := p.absolutePath(fileName, arg)
var fileNames []string
if p.glob == nil {
paths, _ := filepath.Glob(path)
for _, p := range paths {
if s, err := os.Stat(p); err == nil && !s.IsDir() {
fileNames = append(fileNames, p)
}
}
} else {
var err error
fileNames, err = p.glob(path)
if err != nil {
continue
}
}
for _, fileName := range fileNames {
_ = p.read(fileName)
}
}
}
}
return scanner.Err()
}
func (p *sshParser) absolutePath(parentFile, path string) string {
if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") {
return path
}
if strings.HasPrefix(path, "~") {
return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~"))
}
if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") {
return filepath.Join("/etc/ssh", path)
}
return filepath.Join(p.homeDir, ".ssh", path)
}
// ParseSSHConfig constructs a map of SSH hostname aliases based on user and
// system configuration files
func ParseSSHConfig() SSHAliasMap {
@ -52,54 +144,19 @@ func ParseSSHConfig() SSHAliasMap {
"/etc/ssh_config",
"/etc/ssh/ssh_config",
}
p := sshParser{}
if homedir, err := homedir.Dir(); err == nil {
userConfig := filepath.Join(homedir, ".ssh", "config")
configFiles = append([]string{userConfig}, configFiles...)
p.homeDir = homedir
}
openFiles := make([]io.Reader, 0, len(configFiles))
for _, file := range configFiles {
f, err := os.Open(file)
if err != nil {
continue
}
defer f.Close()
openFiles = append(openFiles, f)
_ = p.read(file)
}
return sshParse(openFiles...)
}
func sshParse(r ...io.Reader) SSHAliasMap {
config := make(SSHAliasMap)
for _, file := range r {
_ = sshParseConfig(config, file)
}
return config
}
func sshParseConfig(c SSHAliasMap, file io.Reader) error {
hosts := []string{"*"}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
match := sshHostRE.FindStringSubmatch(line)
if match == nil {
continue
}
names := strings.Fields(match[2])
if strings.EqualFold(match[1], "host") {
hosts = names
} else {
for _, host := range hosts {
for _, name := range names {
c[host] = sshExpandTokens(name, host)
}
}
}
}
return scanner.Err()
return p.aliasMap
}
func sshExpandTokens(text, host string) string {

View file

@ -1,31 +1,127 @@
package git
import (
"bytes"
"fmt"
"io"
"net/url"
"reflect"
"strings"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
)
// TODO: extract assertion helpers into a shared package
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
func Test_sshParser_read(t *testing.T) {
testFiles := map[string]string{
"/etc/ssh/config": heredoc.Doc(`
Include sites/*
`),
"/etc/ssh/sites/cfg1": heredoc.Doc(`
Host s1
Hostname=site1.net
`),
"/etc/ssh/sites/cfg2": heredoc.Doc(`
Host s2
Hostname = site2.net
`),
"HOME/.ssh/config": heredoc.Doc(`
Host *
Host gh gittyhubby
Hostname github.com
#Hostname example.com
Host ex
Include ex_config/*
`),
"HOME/.ssh/ex_config/ex_cfg": heredoc.Doc(`
Hostname example.com
`),
}
globResults := map[string][]string{
"/etc/ssh/sites/*": {"/etc/ssh/sites/cfg1", "/etc/ssh/sites/cfg2"},
"HOME/.ssh/ex_config/*": {"HOME/.ssh/ex_config/ex_cfg"},
}
p := &sshParser{
homeDir: "HOME",
open: func(s string) (io.Reader, error) {
if contents, ok := testFiles[filepath.ToSlash(s)]; ok {
return bytes.NewBufferString(contents), nil
} else {
return nil, fmt.Errorf("no test file stub found: %q", s)
}
},
glob: func(p string) ([]string, error) {
if results, ok := globResults[filepath.ToSlash(p)]; ok {
return results, nil
} else {
return nil, fmt.Errorf("no glob stubs found: %q", p)
}
},
}
if err := p.read("/etc/ssh/config"); err != nil {
t.Fatalf("read(global config) = %v", err)
}
if err := p.read("HOME/.ssh/config"); err != nil {
t.Fatalf("read(user config) = %v", err)
}
if got := p.aliasMap["gh"]; got != "github.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "gh", "github.com", got)
}
if got := p.aliasMap["gittyhubby"]; got != "github.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "gittyhubby", "github.com", got)
}
if got := p.aliasMap["example.com"]; got != "" {
t.Errorf("expected alias %q to expand to %q, got %q", "example.com", "", got)
}
if got := p.aliasMap["ex"]; got != "example.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "ex", "example.com", got)
}
if got := p.aliasMap["s1"]; got != "site1.net" {
t.Errorf("expected alias %q to expand to %q, got %q", "s1", "site1.net", got)
}
}
func Test_sshParse(t *testing.T) {
m := sshParse(strings.NewReader(`
Host foo bar
HostName example.com
`), strings.NewReader(`
Host bar baz
hostname %%%h.net%%
`))
eq(t, m["foo"], "example.com")
eq(t, m["bar"], "%bar.net%")
eq(t, m["nonexistent"], "")
func Test_sshParser_absolutePath(t *testing.T) {
dir := "HOME"
p := &sshParser{homeDir: dir}
tests := map[string]struct {
parentFile string
arg string
want string
wantErr bool
}{
"absolute path": {
parentFile: "/etc/ssh/ssh_config",
arg: "/etc/ssh/config",
want: "/etc/ssh/config",
},
"system relative path": {
parentFile: "/etc/ssh/config",
arg: "configs/*.conf",
want: filepath.Join("/etc", "ssh", "configs", "*.conf"),
},
"user relative path": {
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
arg: "configs/*.conf",
want: filepath.Join(dir, ".ssh", "configs/*.conf"),
},
"shell-like ~ rerefence": {
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
arg: "~/.ssh/*.conf",
want: filepath.Join(dir, ".ssh", "*.conf"),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := p.absolutePath(tt.parentFile, tt.arg); got != tt.want {
t.Errorf("absolutePath(): %q, wants %q", got, tt.want)
}
})
}
}
func Test_Translator(t *testing.T) {