mirror of
https://github.com/neovim/neovim.git
synced 2025-11-24 03:00:38 +00:00
feat(ssh): SSH configuration parser #35027
This commit is contained in:
237
runtime/lua/vim/net/_ssh.lua
Normal file
237
runtime/lua/vim/net/_ssh.lua
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
-- Converted into Lua from https://github.com/cyjake/ssh-config
|
||||||
|
-- TODO (siddhantdev): deal with include directives
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local whitespace_pattern = '%s'
|
||||||
|
local line_break_pattern = '[\r\n]'
|
||||||
|
|
||||||
|
---@param param string
|
||||||
|
local function is_multi_value_directive(param)
|
||||||
|
local multi_value_directives = {
|
||||||
|
'globalknownhostsfile',
|
||||||
|
'host',
|
||||||
|
'ipqos',
|
||||||
|
'sendenv',
|
||||||
|
'userknownhostsfile',
|
||||||
|
'proxycommand',
|
||||||
|
'match',
|
||||||
|
'canonicaldomains',
|
||||||
|
}
|
||||||
|
|
||||||
|
return vim.list_contains(multi_value_directives, param:lower())
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param text string The ssh configuration which needs to be parsed
|
||||||
|
---@return string[] The parsed host names in the configuration
|
||||||
|
function M.parse_ssh_config(text)
|
||||||
|
local i = 1
|
||||||
|
local line = 1
|
||||||
|
|
||||||
|
local function consume()
|
||||||
|
if i <= #text then
|
||||||
|
local char = text:sub(i, i)
|
||||||
|
i = i + 1
|
||||||
|
return char
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local chr = consume()
|
||||||
|
|
||||||
|
local function parse_spaces()
|
||||||
|
local spaces = ''
|
||||||
|
while chr and chr:match(whitespace_pattern) do
|
||||||
|
spaces = spaces .. chr
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
return spaces
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_linebreaks()
|
||||||
|
local breaks = ''
|
||||||
|
while chr and chr:match(line_break_pattern) do
|
||||||
|
line = line + 1
|
||||||
|
breaks = breaks .. chr
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
return breaks
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_parameter_name()
|
||||||
|
local param = ''
|
||||||
|
while chr and not chr:match('[ \t=]') do
|
||||||
|
param = param .. chr
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
return param
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_separator()
|
||||||
|
local sep = parse_spaces()
|
||||||
|
if chr == '=' then
|
||||||
|
sep = sep .. chr
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
return sep .. parse_spaces()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_value()
|
||||||
|
local val = {}
|
||||||
|
local quoted, escaped = false, false
|
||||||
|
|
||||||
|
while chr and not chr:match(line_break_pattern) do
|
||||||
|
if escaped then
|
||||||
|
table.insert(val, chr == '"' and chr or '\\' .. chr)
|
||||||
|
escaped = false
|
||||||
|
elseif chr == '"' and (val == {} or quoted) then
|
||||||
|
quoted = not quoted
|
||||||
|
elseif chr == '\\' then
|
||||||
|
escaped = true
|
||||||
|
elseif chr == '#' and not quoted then
|
||||||
|
break
|
||||||
|
else
|
||||||
|
table.insert(val, chr)
|
||||||
|
end
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
|
||||||
|
if quoted or escaped then
|
||||||
|
error('Unexpected line break at line ' .. line)
|
||||||
|
end
|
||||||
|
|
||||||
|
return vim.trim(table.concat(val))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_comment()
|
||||||
|
while chr and not chr:match(line_break_pattern) do
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
local function parse_multiple_values()
|
||||||
|
local results = {}
|
||||||
|
local val = {}
|
||||||
|
local quoted = false
|
||||||
|
local escaped = false
|
||||||
|
|
||||||
|
while chr and not chr:match(line_break_pattern) do
|
||||||
|
if escaped then
|
||||||
|
table.insert(val, chr == '"' and chr or '\\' .. chr)
|
||||||
|
escaped = false
|
||||||
|
elseif chr == '"' then
|
||||||
|
quoted = not quoted
|
||||||
|
elseif chr == '\\' then
|
||||||
|
escaped = true
|
||||||
|
elseif quoted then
|
||||||
|
table.insert(val, chr)
|
||||||
|
elseif chr:match('[ \t=]') then
|
||||||
|
if val ~= {} then
|
||||||
|
table.insert(results, vim.trim(table.concat(val)))
|
||||||
|
val = {}
|
||||||
|
end
|
||||||
|
elseif chr == '#' and #results > 0 then
|
||||||
|
break
|
||||||
|
else
|
||||||
|
table.insert(val, chr)
|
||||||
|
end
|
||||||
|
chr = consume()
|
||||||
|
end
|
||||||
|
|
||||||
|
if quoted or escaped then
|
||||||
|
error('Unexpected line break at line ' .. line)
|
||||||
|
end
|
||||||
|
|
||||||
|
if val ~= {} then
|
||||||
|
table.insert(results, vim.trim(table.concat(val)))
|
||||||
|
end
|
||||||
|
|
||||||
|
return results
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_directive()
|
||||||
|
local param = parse_parameter_name()
|
||||||
|
local multiple = is_multi_value_directive(param)
|
||||||
|
local _ = parse_separator()
|
||||||
|
local value = multiple and parse_multiple_values() or parse_value()
|
||||||
|
|
||||||
|
local result = {
|
||||||
|
param = param,
|
||||||
|
value = value,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_line()
|
||||||
|
local _ = parse_spaces()
|
||||||
|
if chr == '#' then
|
||||||
|
parse_comment()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local node = parse_directive()
|
||||||
|
local _ = parse_linebreaks()
|
||||||
|
|
||||||
|
return node
|
||||||
|
end
|
||||||
|
|
||||||
|
local hostnames = {}
|
||||||
|
|
||||||
|
---@param value string
|
||||||
|
local function is_valid(value)
|
||||||
|
return not (value:find('[?*!]') or vim.list_contains(hostnames, value))
|
||||||
|
end
|
||||||
|
|
||||||
|
while chr do
|
||||||
|
local node = parse_line()
|
||||||
|
if node then
|
||||||
|
-- This is done just to assign the type
|
||||||
|
node.value = node.value ---@type string[]
|
||||||
|
if node.param:lower() == 'match' and node.value then
|
||||||
|
local current = nil
|
||||||
|
for ind, val in ipairs(node.value) do
|
||||||
|
if val:lower() == 'host' and ind + 1 <= #node.value and is_valid(node.value[ind + 1]) then
|
||||||
|
current = node.value[ind + 1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if current then
|
||||||
|
table.insert(hostnames, current)
|
||||||
|
end
|
||||||
|
elseif node.param:lower() == 'host' and node.value then
|
||||||
|
for _, value in ipairs(node.value) do
|
||||||
|
if is_valid(value) then
|
||||||
|
table.insert(hostnames, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return hostnames
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param filename string
|
||||||
|
---@return string[] The hostnames configured in the file located at filename
|
||||||
|
function M.parse_config(filename)
|
||||||
|
local file = io.open(filename, 'r')
|
||||||
|
if not file then
|
||||||
|
error('Cannot read ssh configuration file')
|
||||||
|
end
|
||||||
|
local config_string = file:read('*a')
|
||||||
|
file:close()
|
||||||
|
|
||||||
|
return M.parse_ssh_config(config_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[] The hostnames configured in the ssh configuration file
|
||||||
|
--- located at "~/.ssh/config".
|
||||||
|
--- Note: This does not currently process `Include` directives in the
|
||||||
|
--- configuration file.
|
||||||
|
function M.get_hosts()
|
||||||
|
local config_path = vim.fs.normalize('~/.ssh/config') ---@type string
|
||||||
|
|
||||||
|
return M.parse_config(config_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
78
test/functional/lua/ssh_spec.lua
Normal file
78
test/functional/lua/ssh_spec.lua
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
local t = require('test.testutil')
|
||||||
|
local parser = require('vim.net._ssh')
|
||||||
|
local eq = t.eq
|
||||||
|
|
||||||
|
describe('SSH parser', function()
|
||||||
|
it('parses SSH configuration strings', function()
|
||||||
|
local config = [[
|
||||||
|
Host *
|
||||||
|
ConnectTimeout 10
|
||||||
|
ServerAliveInterval 60
|
||||||
|
ServerAliveCountMax 3
|
||||||
|
# Use a specific key for any host not otherwise specified
|
||||||
|
# IdentityFile ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
Host=dev
|
||||||
|
HostName=dev.example.com
|
||||||
|
User=devuser
|
||||||
|
Port=2222
|
||||||
|
IdentityFile=~/.ssh/id_rsa_dev
|
||||||
|
|
||||||
|
Host prod test
|
||||||
|
HostName 198.51.100.10
|
||||||
|
User admin
|
||||||
|
Port 22
|
||||||
|
IdentityFile ~/.ssh/id_rsa_prod
|
||||||
|
ForwardAgent yes
|
||||||
|
|
||||||
|
Host test
|
||||||
|
IdentitiesOnly yes
|
||||||
|
|
||||||
|
Host "quoted string"
|
||||||
|
User quote
|
||||||
|
Port 22
|
||||||
|
|
||||||
|
Match host foo host gh
|
||||||
|
HostName github.com
|
||||||
|
User git
|
||||||
|
IdentityFile ~/.ssh/id_rsa_github
|
||||||
|
IdentitiesOnly yes
|
||||||
|
]]
|
||||||
|
|
||||||
|
eq({
|
||||||
|
'dev',
|
||||||
|
'prod',
|
||||||
|
'test',
|
||||||
|
'quoted string',
|
||||||
|
'gh',
|
||||||
|
}, parser.parse_ssh_config(config))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('fails when a quote is not closed', function()
|
||||||
|
local config = [[
|
||||||
|
Host prod dev "test prod my
|
||||||
|
HostName 198.51.100.10
|
||||||
|
User admin
|
||||||
|
Port 22
|
||||||
|
IdentityFile ~/.ssh/id_rsa_prod
|
||||||
|
ForwardAgent yes
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ok, _ = pcall(parser.parse_ssh_config, config)
|
||||||
|
eq(false, ok)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('fails when the line ends with a single backslash', function()
|
||||||
|
local config = [[
|
||||||
|
Host prod test
|
||||||
|
HostName 198.51.100.10
|
||||||
|
User admin\
|
||||||
|
Port 22
|
||||||
|
IdentityFile ~/.ssh/id_rsa_prod
|
||||||
|
ForwardAgent yes
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ok, _ = pcall(parser.parse_ssh_config, config)
|
||||||
|
eq(false, ok)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
Reference in New Issue
Block a user