From f1922e78a1df1b1d32779769432fb5586edf5fbb Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sat, 5 Nov 2022 13:37:05 -0600 Subject: feat: add vim.secure.read() This function accepts a path to a file and prompts the user if the file is trusted. If the user confirms that the file is trusted, the contents of the file are returned. The user's decision is stored in a trust database at $XDG_STATE_HOME/nvim/trust. When this function is invoked with a path that is already marked as trusted in the trust database, the user is not prompted for a response. --- runtime/lua/vim/secure.lua | 106 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 runtime/lua/vim/secure.lua (limited to 'runtime/lua/vim/secure.lua') diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua new file mode 100644 index 0000000000..341ff8df05 --- /dev/null +++ b/runtime/lua/vim/secure.lua @@ -0,0 +1,106 @@ +local M = {} + +--- Attempt to read the file at {path} prompting the user if the file should be +--- trusted. The user's choice is persisted in a trust database at +--- $XDG_STATE_HOME/nvim/trust. +--- +---@param path (string) Path to a file to read. +--- +---@return (string|nil) The contents of the given file if it exists and is +--- trusted, or nil otherwise. +function M.read(path) + vim.validate({ path = { path, 's' } }) + local fullpath = vim.loop.fs_realpath(vim.fs.normalize(path)) + if not fullpath then + return nil + end + + local trust = {} + do + local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r') + if f then + local contents = f:read('*a') + if contents then + for line in vim.gsplit(contents, '\n') do + local hash, file = string.match(line, '^(%S+) (.+)$') + if hash and file then + trust[file] = hash + end + end + end + f:close() + end + end + + if trust[fullpath] == '!' then + -- File is denied + return nil + end + + local contents + do + local f = io.open(fullpath, 'r') + if not f then + return nil + end + contents = f:read('*a') + f:close() + end + + local hash = vim.fn.sha256(contents) + if trust[fullpath] == hash then + -- File already exists in trust database + return contents + end + + -- File either does not exist in trust database or the hash does not match + local choice = vim.fn.confirm( + string.format('%s is not trusted.', fullpath), + '&ignore\n&view\n&deny\n&allow', + 1 + ) + + if choice == 0 or choice == 1 then + -- Cancelled or ignored + return nil + elseif choice == 2 then + -- View + vim.cmd('new') + local buf = vim.api.nvim_get_current_buf() + local lines = vim.split(string.gsub(contents, '\n$', ''), '\n') + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].bufhidden = 'hide' + vim.bo[buf].buftype = 'nofile' + vim.bo[buf].swapfile = false + vim.bo[buf].modeline = false + vim.bo[buf].buflisted = false + vim.bo[buf].readonly = true + vim.bo[buf].modifiable = false + return nil + elseif choice == 3 then + -- Deny + trust[fullpath] = '!' + contents = nil + elseif choice == 4 then + -- Allow + trust[fullpath] = hash + end + + do + local f, err = io.open(vim.fn.stdpath('state') .. '/trust', 'w') + if not f then + error(err) + end + + local t = {} + for p, h in pairs(trust) do + t[#t + 1] = string.format('%s %s\n', h, p) + end + f:write(table.concat(t)) + f:close() + end + + return contents +end + +return M -- cgit From f004812b338340e5f5157aa68d09d3f0e5605c6c Mon Sep 17 00:00:00 2001 From: Jlll1 Date: Mon, 28 Nov 2022 20:23:04 +0100 Subject: feat(secure): add `:trust` command and vim.secure.trust() (#21107) Introduce vim.secure.trust() to programmatically manage the trust database. Use this function in a new :trust ex command which can be used as a simple frontend. Resolves: https://github.com/neovim/neovim/issues/21092 Co-authored-by: Gregory Anders Co-authored-by: ii14 --- runtime/lua/vim/secure.lua | 139 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 26 deletions(-) (limited to 'runtime/lua/vim/secure.lua') diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua index 341ff8df05..0b4d7d53a2 100644 --- a/runtime/lua/vim/secure.lua +++ b/runtime/lua/vim/secure.lua @@ -1,9 +1,50 @@ local M = {} +---@private +--- Reads trust database from $XDG_STATE_HOME/nvim/trust. +--- +---@return (table) Contents of trust database, if it exists. Empty table otherwise. +local function read_trust() + local trust = {} + local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r') + if f then + local contents = f:read('*a') + if contents then + for line in vim.gsplit(contents, '\n') do + local hash, file = string.match(line, '^(%S+) (.+)$') + if hash and file then + trust[file] = hash + end + end + end + f:close() + end + return trust +end + +---@private +--- Writes provided {trust} table to trust database at +--- $XDG_STATE_HOME/nvim/trust. +--- +---@param trust (table) Trust table to write +local function write_trust(trust) + vim.validate({ trust = { trust, 't' } }) + local f = assert(io.open(vim.fn.stdpath('state') .. '/trust', 'w')) + + local t = {} + for p, h in pairs(trust) do + t[#t + 1] = string.format('%s %s\n', h, p) + end + f:write(table.concat(t)) + f:close() +end + --- Attempt to read the file at {path} prompting the user if the file should be --- trusted. The user's choice is persisted in a trust database at --- $XDG_STATE_HOME/nvim/trust. --- +---@see |:trust| +--- ---@param path (string) Path to a file to read. --- ---@return (string|nil) The contents of the given file if it exists and is @@ -15,22 +56,7 @@ function M.read(path) return nil end - local trust = {} - do - local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r') - if f then - local contents = f:read('*a') - if contents then - for line in vim.gsplit(contents, '\n') do - local hash, file = string.match(line, '^(%S+) (.+)$') - if hash and file then - trust[file] = hash - end - end - end - f:close() - end - end + local trust = read_trust() if trust[fullpath] == '!' then -- File is denied @@ -86,21 +112,82 @@ function M.read(path) trust[fullpath] = hash end - do - local f, err = io.open(vim.fn.stdpath('state') .. '/trust', 'w') - if not f then - error(err) + write_trust(trust) + + return contents +end + +--- Manage the trust database. +--- +--- The trust database is located at |$XDG_STATE_HOME|/nvim/trust. +--- +---@param opts (table): +--- - action (string): "allow" to add a file to the trust database and trust it, +--- "deny" to add a file to the trust database and deny it, +--- "remove" to remove file from the trust database +--- - path (string|nil): Path to a file to update. Mutually exclusive with {bufnr}. +--- Cannot be used when {action} is "allow". +--- - bufnr (number|nil): Buffer number to update. Mutually exclusive with {path}. +---@return (boolean, string) success, msg: +--- - true and full path of target file if operation was successful +--- - false and error message on failure +function M.trust(opts) + vim.validate({ + path = { opts.path, 's', true }, + bufnr = { opts.bufnr, 'n', true }, + action = { + opts.action, + function(m) + return m == 'allow' or m == 'deny' or m == 'remove' + end, + [["allow" or "deny" or "remove"]], + }, + }) + + local path = opts.path + local bufnr = opts.bufnr + local action = opts.action + + if path and bufnr then + error('path and bufnr are mutually exclusive', 2) + end + + local fullpath + if path then + fullpath = vim.loop.fs_realpath(vim.fs.normalize(path)) + else + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname == '' then + return false, 'buffer is not associated with a file' end + fullpath = vim.loop.fs_realpath(vim.fs.normalize(bufname)) + end + + if not fullpath then + return false, string.format('invalid path: %s', path) + end + + local trust = read_trust() + + if action == 'allow' then + assert(bufnr, 'bufnr is required when action is "allow"') - local t = {} - for p, h in pairs(trust) do - t[#t + 1] = string.format('%s %s\n', h, p) + local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n' + local contents = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), newline) + if vim.bo[bufnr].endofline then + contents = contents .. newline end - f:write(table.concat(t)) - f:close() + local hash = vim.fn.sha256(contents) + + trust[fullpath] = hash + elseif action == 'deny' then + trust[fullpath] = '!' + elseif action == 'remove' then + trust[fullpath] = nil end - return contents + write_trust(trust) + return true, fullpath end return M -- cgit From 80b6edabe3e4203ee4bf50261af07a6a0495ef36 Mon Sep 17 00:00:00 2001 From: Gregory Anders <8965202+gpanders@users.noreply.github.com> Date: Mon, 28 Nov 2022 15:40:50 -0700 Subject: refactor: rework parameter validation in vim.secure.trust() (#21223) --- runtime/lua/vim/secure.lua | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'runtime/lua/vim/secure.lua') diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua index 0b4d7d53a2..08b1ff871c 100644 --- a/runtime/lua/vim/secure.lua +++ b/runtime/lua/vim/secure.lua @@ -148,19 +148,23 @@ function M.trust(opts) local bufnr = opts.bufnr local action = opts.action - if path and bufnr then - error('path and bufnr are mutually exclusive', 2) + assert(not path or not bufnr, '"path" and "bufnr" are mutually exclusive') + + if action == 'allow' then + assert(not path, '"path" is not valid when action is "allow"') end local fullpath if path then fullpath = vim.loop.fs_realpath(vim.fs.normalize(path)) - else + elseif bufnr then local bufname = vim.api.nvim_buf_get_name(bufnr) if bufname == '' then return false, 'buffer is not associated with a file' end fullpath = vim.loop.fs_realpath(vim.fs.normalize(bufname)) + else + error('one of "path" or "bufnr" is required') end if not fullpath then @@ -170,8 +174,6 @@ function M.trust(opts) local trust = read_trust() if action == 'allow' then - assert(bufnr, 'bufnr is required when action is "allow"') - local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n' local contents = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), newline) if vim.bo[bufnr].endofline then -- cgit From f3bf1fbf600050fde155e6a1a766b6f848012208 Mon Sep 17 00:00:00 2001 From: ii14 <59243201+ii14@users.noreply.github.com> Date: Mon, 5 Dec 2022 19:59:04 +0100 Subject: fix(secure): crash when hitting escape in prompt (#21283) - use pcall when calling vim.secure.read from C - catch keyboard interrupts in vim.secure.read, rethrow other errors - selecting "view" in prompt runs :view command - simplify lua stack cleanup with lua_gettop and lua_settop Co-authored-by: ii14 --- runtime/lua/vim/secure.lua | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) (limited to 'runtime/lua/vim/secure.lua') diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua index 08b1ff871c..443b152273 100644 --- a/runtime/lua/vim/secure.lua +++ b/runtime/lua/vim/secure.lua @@ -80,34 +80,27 @@ function M.read(path) end -- File either does not exist in trust database or the hash does not match - local choice = vim.fn.confirm( + local ok, result = pcall( + vim.fn.confirm, string.format('%s is not trusted.', fullpath), '&ignore\n&view\n&deny\n&allow', 1 ) - if choice == 0 or choice == 1 then + if not ok and result ~= 'Keyboard interrupt' then + error(result) + elseif not ok or result == 0 or result == 1 then -- Cancelled or ignored return nil - elseif choice == 2 then + elseif result == 2 then -- View - vim.cmd('new') - local buf = vim.api.nvim_get_current_buf() - local lines = vim.split(string.gsub(contents, '\n$', ''), '\n') - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].bufhidden = 'hide' - vim.bo[buf].buftype = 'nofile' - vim.bo[buf].swapfile = false - vim.bo[buf].modeline = false - vim.bo[buf].buflisted = false - vim.bo[buf].readonly = true - vim.bo[buf].modifiable = false + vim.cmd('sview ' .. fullpath) return nil - elseif choice == 3 then + elseif result == 3 then -- Deny trust[fullpath] = '!' contents = nil - elseif choice == 4 then + elseif result == 4 then -- Allow trust[fullpath] = hash end -- cgit