fix(trust): support for trusting directories #33735

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.

(cherry picked from commit 272dba7f07)
This commit is contained in:
Jeremy Fleischman
2025-04-30 14:35:41 -07:00
committed by GitHub
parent 4b6caa913c
commit 560c6ca947
4 changed files with 213 additions and 31 deletions

View File

@ -3740,19 +3740,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|

View File

@ -171,6 +171,9 @@ API
aligned text that truncates before covering up buffer text.
• `virt_lines_overflow` field accepts value `scroll` to enable horizontal
scrolling for virtual lines with 'nowrap'.
• |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

View File

@ -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

View File

@ -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 },
@ -94,7 +102,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')
@ -144,6 +152,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()
@ -160,10 +276,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()
@ -275,5 +393,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)