1package git
2
3import (
4	"bufio"
5	"io"
6	"net/url"
7	"os"
8	"path/filepath"
9	"regexp"
10	"strings"
11
12	"github.com/cli/cli/v2/internal/config"
13)
14
15var (
16	sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
17	sshTokenRE      = regexp.MustCompile(`%[%h]`)
18)
19
20// SSHAliasMap encapsulates the translation of SSH hostname aliases
21type SSHAliasMap map[string]string
22
23// Translator returns a function that applies hostname aliases to URLs
24func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
25	return func(u *url.URL) *url.URL {
26		if u.Scheme != "ssh" {
27			return u
28		}
29		resolvedHost, ok := m[u.Hostname()]
30		if !ok {
31			return u
32		}
33		if strings.EqualFold(resolvedHost, "ssh.github.com") {
34			resolvedHost = "github.com"
35		}
36		newURL, _ := url.Parse(u.String())
37		newURL.Host = resolvedHost
38		return newURL
39	}
40}
41
42type sshParser struct {
43	homeDir string
44
45	aliasMap SSHAliasMap
46	hosts    []string
47
48	open func(string) (io.Reader, error)
49	glob func(string) ([]string, error)
50}
51
52func (p *sshParser) read(fileName string) error {
53	var file io.Reader
54	if p.open == nil {
55		f, err := os.Open(fileName)
56		if err != nil {
57			return err
58		}
59		defer f.Close()
60		file = f
61	} else {
62		var err error
63		file, err = p.open(fileName)
64		if err != nil {
65			return err
66		}
67	}
68
69	if len(p.hosts) == 0 {
70		p.hosts = []string{"*"}
71	}
72
73	scanner := bufio.NewScanner(file)
74	for scanner.Scan() {
75		m := sshConfigLineRE.FindStringSubmatch(scanner.Text())
76		if len(m) < 3 {
77			continue
78		}
79
80		keyword, arguments := strings.ToLower(m[1]), m[2]
81		switch keyword {
82		case "host":
83			p.hosts = strings.Fields(arguments)
84		case "hostname":
85			for _, host := range p.hosts {
86				for _, name := range strings.Fields(arguments) {
87					if p.aliasMap == nil {
88						p.aliasMap = make(SSHAliasMap)
89					}
90					p.aliasMap[host] = sshExpandTokens(name, host)
91				}
92			}
93		case "include":
94			for _, arg := range strings.Fields(arguments) {
95				path := p.absolutePath(fileName, arg)
96
97				var fileNames []string
98				if p.glob == nil {
99					paths, _ := filepath.Glob(path)
100					for _, p := range paths {
101						if s, err := os.Stat(p); err == nil && !s.IsDir() {
102							fileNames = append(fileNames, p)
103						}
104					}
105				} else {
106					var err error
107					fileNames, err = p.glob(path)
108					if err != nil {
109						continue
110					}
111				}
112
113				for _, fileName := range fileNames {
114					_ = p.read(fileName)
115				}
116			}
117		}
118	}
119
120	return scanner.Err()
121}
122
123func (p *sshParser) absolutePath(parentFile, path string) string {
124	if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") {
125		return path
126	}
127
128	if strings.HasPrefix(path, "~") {
129		return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~"))
130	}
131
132	if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") {
133		return filepath.Join("/etc/ssh", path)
134	}
135
136	return filepath.Join(p.homeDir, ".ssh", path)
137}
138
139// ParseSSHConfig constructs a map of SSH hostname aliases based on user and
140// system configuration files
141func ParseSSHConfig() SSHAliasMap {
142	configFiles := []string{
143		"/etc/ssh_config",
144		"/etc/ssh/ssh_config",
145	}
146
147	p := sshParser{}
148
149	if sshDir, err := config.HomeDirPath(".ssh"); err == nil {
150		userConfig := filepath.Join(sshDir, "config")
151		configFiles = append([]string{userConfig}, configFiles...)
152		p.homeDir = filepath.Dir(sshDir)
153	}
154
155	for _, file := range configFiles {
156		_ = p.read(file)
157	}
158	return p.aliasMap
159}
160
161func sshExpandTokens(text, host string) string {
162	return sshTokenRE.ReplaceAllStringFunc(text, func(match string) string {
163		switch match {
164		case "%h":
165			return host
166		case "%%":
167			return "%"
168		}
169		return ""
170	})
171}
172