commit 272dba7f07f82f260567a792ae824e98c52e3709
parent 0ab0cdb2dabc551f836851aa85d06e927c42d92a
Author: Jeremy Fleischman <jeremyfleischman@gmail.com>
Date: Wed, 30 Apr 2025 04:20:39 -0700
fix(trust): support for trusting directories #33617
Problem:
Directories that are "trusted" by `vim.secure.read()`, are not detectable later
(they will prompt again). https://github.com/neovim/neovim/discussions/33587#discussioncomment-12925887
Solution:
`vim.secure.read()` returns `true` if the user trusts a directory.
Also fix other bugs:
- If `f:read('*a')` returns `nil`, we treat that as a successful read of
the file, and hash it. `f:read` returns `nil` for directories, but
it's also documented as returning `nil` "if it cannot read data with the
specified format". I reworked the implementation so we explicitly
treat directories differently. Rather than hashing `nil` to put in the
trust database, we now put "directory" in there explicitly*.
- `vim.secure.trust` (used by `:trust`) didn't actually work for
directories, as it would blindly read the contents of a netrw buffer
and hash it. Now it uses the same codepath as `vim.secure.read`, and
as a result, works correctly for directories.
Diffstat:
4 files changed, 213 insertions(+), 31 deletions(-)
diff --git 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
@@ -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
@@ -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
@@ -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)