diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 725b1a0f6f..06844982b5 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -3742,19 +3742,25 @@ vim.regex({re}) *vim.regex()* Lua module: vim.secure *vim.secure* vim.secure.read({path}) *vim.secure.read()* - 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 + If {path} is a file: attempt to read the file, prompting the user if the + file should be trusted. + + If {path} is a directory: return true if the directory is trusted + (non-recursive), prompting the user as necessary. + + The user's choice is persisted in a trust database at $XDG_STATE_HOME/nvim/trust. Attributes: ~ Since: 0.9.0 Parameters: ~ - • {path} (`string`) Path to a file to read. + • {path} (`string`) Path to a file or directory to read. Return: ~ - (`string?`) The contents of the given file if it exists and is - trusted, or nil otherwise. + (`boolean|string?`) If {path} is not trusted or does not exist, + returns `nil`. Otherwise, returns the contents of {path} if it is a + file, or true if {path} is a directory. See also: ~ • |:trust| diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index a69520b8e2..a2a3749110 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -106,6 +106,9 @@ API • |nvim_win_text_height()| can limit the lines checked when a certain `max_height` is reached, and returns the `end_row` and `end_vcol` for which `max_height` or the calculated height is reached. +• |vim.secure.read()| now returns `true` for trusted directories. Previously + it would return `nil`, which made it impossible to tell if the directory was + actually trusted. DEFAULTS diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua index 7b1d071270..169214871a 100644 --- a/runtime/lua/vim/secure.lua +++ b/runtime/lua/vim/secure.lua @@ -21,6 +21,50 @@ local function read_trust() return trust end +--- If {fullpath} is a file, read the contents of {fullpath} (or the contents of {bufnr} +--- if given) and returns the contents and a hash of the contents. +--- +--- If {fullpath} is a directory, then nothing is read from the filesystem, and +--- `contents = true` and `hash = "directory"` is returned instead. +--- +---@param fullpath (string) Path to a file or directory to read. +---@param bufnr (number?) The number of the buffer. +---@return string|boolean? contents the contents of the file, or true if it's a directory +---@return string? hash the hash of the contents, or "directory" if it's a directory +local function compute_hash(fullpath, bufnr) + local contents ---@type string|boolean? + local hash ---@type string + if vim.fn.isdirectory(fullpath) == 1 then + return true, 'directory' + end + + if bufnr then + local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n' + contents = + table.concat(vim.api.nvim_buf_get_lines(bufnr --[[@as integer]], 0, -1, false), newline) + if vim.bo[bufnr].endofline then + contents = contents .. newline + end + else + do + local f = io.open(fullpath, 'r') + if not f then + return nil, nil + end + contents = f:read('*a') + f:close() + end + + if not contents then + return nil, nil + end + end + + hash = vim.fn.sha256(contents) + + return contents, hash +end + --- Writes provided {trust} table to trust database at --- $XDG_STATE_HOME/nvim/trust. --- @@ -37,17 +81,22 @@ local function write_trust(trust) 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 +--- If {path} is a file: attempt to read the file, prompting the user if the file should be +--- trusted. +--- +--- If {path} is a directory: return true if the directory is trusted (non-recursive), prompting +--- the user as necessary. +--- +--- The user's choice is persisted in a trust database at --- $XDG_STATE_HOME/nvim/trust. --- ---@since 11 ---@see |:trust| --- ----@param path (string) Path to a file to read. +---@param path (string) Path to a file or directory to read. --- ----@return (string|nil) The contents of the given file if it exists and is ---- trusted, or nil otherwise. +---@return (boolean|string|nil) If {path} is not trusted or does not exist, returns `nil`. Otherwise, +--- returns the contents of {path} if it is a file, or true if {path} is a directory. function M.read(path) vim.validate('path', path, 'string') local fullpath = vim.uv.fs_realpath(vim.fs.normalize(path)) @@ -62,26 +111,25 @@ function M.read(path) return nil end - local contents ---@type string? - do - local f = io.open(fullpath, 'r') - if not f then - return nil - end - contents = f:read('*a') - f:close() + local contents, hash = compute_hash(fullpath, nil) + if not contents then + return nil end - local hash = vim.fn.sha256(contents) if trust[fullpath] == hash then -- File already exists in trust database return contents end + local dir_msg = '' + if hash == 'directory' then + dir_msg = ' DIRECTORY trust is decided only by its name, not its contents.' + end + -- File either does not exist in trust database or the hash does not match local ok, result = pcall( vim.fn.confirm, - string.format('%s is not trusted.', fullpath), + string.format('%s is not trusted.%s', fullpath, dir_msg), '&ignore\n&view\n&deny\n&allow', 1 ) @@ -169,13 +217,10 @@ function M.trust(opts) local trust = read_trust() if action == 'allow' then - local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n' - local contents = - table.concat(vim.api.nvim_buf_get_lines(bufnr --[[@as integer]], 0, -1, false), newline) - if vim.bo[bufnr].endofline then - contents = contents .. newline + local contents, hash = compute_hash(fullpath, bufnr) + if not contents then + return false, string.format('could not read path: %s', fullpath) end - local hash = vim.fn.sha256(contents) trust[fullpath] = hash elseif action == 'deny' then diff --git a/test/functional/lua/secure_spec.lua b/test/functional/lua/secure_spec.lua index 912d97a3b5..71e2ff9d3d 100644 --- a/test/functional/lua/secure_spec.lua +++ b/test/functional/lua/secure_spec.lua @@ -20,25 +20,33 @@ local read_file = t.read_file describe('vim.secure', function() describe('read()', function() local xstate = 'Xstate' + local screen ---@type test.functional.ui.screen - setup(function() + before_each(function() clear { env = { XDG_STATE_HOME = xstate } } n.mkdir_p(xstate .. pathsep .. (is_os('win') and 'nvim-data' or 'nvim')) + + t.mkdir('Xdir') + t.mkdir('Xdir/Xsubdir') + t.write_file('Xdir/Xfile.txt', [[hello, world]]) + t.write_file( 'Xfile', [[ let g:foobar = 42 ]] ) + screen = Screen.new(500, 8) end) - teardown(function() + after_each(function() + screen:detach() os.remove('Xfile') + n.rmdir('Xdir') n.rmdir(xstate) end) - it('works', function() - local screen = Screen.new(500, 8) + it('regular file', function() screen:set_default_attr_ids({ [1] = { bold = true, foreground = Screen.colors.Blue1 }, [2] = { bold = true, reverse = true }, @@ -95,7 +103,7 @@ describe('vim.secure', function() local hash = fn.sha256(assert(read_file('Xfile'))) trust = assert(read_file(stdpath('state') .. pathsep .. 'trust')) eq(string.format('%s %s', hash, cwd .. pathsep .. 'Xfile'), vim.trim(trust)) - eq(vim.NIL, exec_lua([[vim.secure.read('Xfile')]])) + eq('let g:foobar = 42\n', exec_lua([[return vim.secure.read('Xfile')]])) os.remove(stdpath('state') .. pathsep .. 'trust') @@ -145,6 +153,114 @@ describe('vim.secure', function() pcall_err(command, 'write') eq(true, api.nvim_get_option_value('readonly', {})) end) + + it('directory', function() + screen:set_default_attr_ids({ + [1] = { bold = true, foreground = Screen.colors.Blue1 }, + [2] = { bold = true, reverse = true }, + [3] = { bold = true, foreground = Screen.colors.SeaGreen }, + [4] = { reverse = true }, + }) + + local cwd = fn.getcwd() + local msg = cwd + .. pathsep + .. 'Xdir is not trusted. DIRECTORY trust is decided only by its name, not its contents.' + if #msg >= screen._width then + pending('path too long') + return + end + + -- Need to use feed_command instead of exec_lua because of the confirmation prompt + feed_command([[lua vim.secure.read('Xdir')]]) + screen:expect([[ + {MATCH: +}| + {1:~{MATCH: +}}|*3 + {2:{MATCH: +}}| + :lua vim.secure.read('Xdir'){MATCH: +}| + {3:]] .. msg .. [[}{MATCH: +}| + {3:[i]gnore, (v)iew, (d)eny, (a)llow: }^{MATCH: +}| + ]]) + feed('d') + screen:expect([[ + ^{MATCH: +}| + {1:~{MATCH: +}}|*6 + {MATCH: +}| + ]]) + + local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust')) + eq(string.format('! %s', cwd .. pathsep .. 'Xdir'), vim.trim(trust)) + eq(vim.NIL, exec_lua([[return vim.secure.read('Xdir')]])) + + os.remove(stdpath('state') .. pathsep .. 'trust') + + feed_command([[lua vim.secure.read('Xdir')]]) + screen:expect([[ + {MATCH: +}| + {1:~{MATCH: +}}|*3 + {2:{MATCH: +}}| + :lua vim.secure.read('Xdir'){MATCH: +}| + {3:]] .. msg .. [[}{MATCH: +}| + {3:[i]gnore, (v)iew, (d)eny, (a)llow: }^{MATCH: +}| + ]]) + feed('a') + screen:expect([[ + ^{MATCH: +}| + {1:~{MATCH: +}}|*6 + {MATCH: +}| + ]]) + + -- Directories aren't hashed in the trust database, instead a slug ("directory") is stored + -- instead. + local expected_hash = 'directory' + trust = assert(read_file(stdpath('state') .. pathsep .. 'trust')) + eq(string.format('%s %s', expected_hash, cwd .. pathsep .. 'Xdir'), vim.trim(trust)) + eq(true, exec_lua([[return vim.secure.read('Xdir')]])) + + os.remove(stdpath('state') .. pathsep .. 'trust') + + feed_command([[lua vim.secure.read('Xdir')]]) + screen:expect([[ + {MATCH: +}| + {1:~{MATCH: +}}|*3 + {2:{MATCH: +}}| + :lua vim.secure.read('Xdir'){MATCH: +}| + {3:]] .. msg .. [[}{MATCH: +}| + {3:[i]gnore, (v)iew, (d)eny, (a)llow: }^{MATCH: +}| + ]]) + feed('i') + screen:expect([[ + ^{MATCH: +}| + {1:~{MATCH: +}}|*6 + {MATCH: +}| + ]]) + + -- Trust database is not updated + eq(nil, read_file(stdpath('state') .. pathsep .. 'trust')) + + feed_command([[lua vim.secure.read('Xdir')]]) + screen:expect([[ + {MATCH: +}| + {1:~{MATCH: +}}|*3 + {2:{MATCH: +}}| + :lua vim.secure.read('Xdir'){MATCH: +}| + {3:]] .. msg .. [[}{MATCH: +}| + {3:[i]gnore, (v)iew, (d)eny, (a)llow: }^{MATCH: +}| + ]]) + feed('v') + screen:expect([[ + ^{MATCH: +}| + {1:~{MATCH: +}}|*2 + {2:]] .. fn.fnamemodify(cwd, ':~') .. pathsep .. [[Xdir [RO]{MATCH: +}}| + {MATCH: +}| + {1:~{MATCH: +}}| + {4:[No Name]{MATCH: +}}| + {MATCH: +}| + ]]) + + -- Trust database is not updated + eq(nil, read_file(stdpath('state') .. pathsep .. 'trust')) + end) end) describe('trust()', function() @@ -161,10 +277,12 @@ describe('vim.secure', function() before_each(function() t.write_file('test_file', 'test') + t.mkdir('test_dir') end) after_each(function() os.remove('test_file') + n.rmdir('test_dir') end) it('returns error when passing both path and bufnr', function() @@ -276,5 +394,15 @@ describe('vim.secure', function() exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]) ) end) + + it('trust directory bufnr', function() + local cwd = fn.getcwd() + local full_path = cwd .. pathsep .. 'test_dir' + command('edit test_dir') + + eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]])) + local trust = read_file(stdpath('state') .. pathsep .. 'trust') + eq(string.format('directory %s', full_path), vim.trim(trust)) + end) end) end)