mirror of
				https://github.com/neovim/neovim.git
				synced 2025-10-26 12:27:24 +00:00 
			
		
		
		
	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
					Jeremy Fleischman
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							4b6caa913c
						
					
				
				
					commit
					560c6ca947
				
			| @@ -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| | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user