secure.lua (6622B)
1 local M = {} 2 3 --- Reads trust database from $XDG_STATE_HOME/nvim/trust. 4 --- 5 ---@return table<string, string> Contents of trust database, if it exists. Empty table otherwise. 6 local function read_trust() 7 local trust = {} ---@type table<string, string> 8 local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r') 9 if f then 10 local contents = f:read('*a') 11 if contents then 12 for line in vim.gsplit(contents, '\n') do 13 local hash, file = string.match(line, '^(%S+) (.+)$') 14 if hash and file then 15 trust[file] = hash 16 end 17 end 18 end 19 f:close() 20 end 21 return trust 22 end 23 24 --- If {fullpath} is a file, read the contents of {fullpath} (or the contents of {bufnr} 25 --- if given) and returns the contents and a hash of the contents. 26 --- 27 --- If {fullpath} is a directory, then nothing is read from the filesystem, and 28 --- `contents = true` and `hash = "directory"` is returned instead. 29 --- 30 ---@param fullpath string Path to a file or directory to read. 31 ---@param bufnr integer? The number of the buffer. 32 ---@return string|boolean? contents the contents of the file, or true if it's a directory 33 ---@return string? hash the hash of the contents, or "directory" if it's a directory 34 local function compute_hash(fullpath, bufnr) 35 local contents ---@type string|boolean? 36 local hash ---@type string 37 if vim.fn.isdirectory(fullpath) == 1 then 38 return true, 'directory' 39 end 40 41 if bufnr then 42 local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n' 43 contents = 44 table.concat(vim.api.nvim_buf_get_lines(bufnr --[[@as integer]], 0, -1, false), newline) 45 if vim.bo[bufnr].endofline then 46 contents = contents .. newline 47 end 48 else 49 do 50 local f = io.open(fullpath, 'rb') 51 if not f then 52 return nil, nil 53 end 54 contents = f:read('*a') 55 f:close() 56 end 57 58 if not contents then 59 return nil, nil 60 end 61 end 62 63 hash = vim.fn.sha256(contents) 64 65 return contents, hash 66 end 67 68 --- Writes provided {trust} table to trust database at 69 --- $XDG_STATE_HOME/nvim/trust. 70 --- 71 ---@param trust table<string, string> Trust table to write 72 local function write_trust(trust) 73 vim.validate('trust', trust, 'table') 74 local f = assert(io.open(vim.fn.stdpath('state') .. '/trust', 'w')) 75 76 local t = {} ---@type string[] 77 for p, h in pairs(trust) do 78 t[#t + 1] = string.format('%s %s\n', h, p) 79 end 80 f:write(table.concat(t)) 81 f:close() 82 end 83 84 --- If {path} is a file: attempt to read the file, prompting the user if the file should be 85 --- trusted. 86 --- 87 --- If {path} is a directory: return true if the directory is trusted (non-recursive), prompting 88 --- the user as necessary. 89 --- 90 --- The user's choice is persisted in a trust database at 91 --- $XDG_STATE_HOME/nvim/trust. 92 --- 93 ---@since 11 94 ---@see |:trust| 95 --- 96 ---@param path (string) Path to a file or directory to read. 97 --- 98 ---@return (boolean|string|nil) If {path} is not trusted or does not exist, returns `nil`. Otherwise, 99 --- returns the contents of {path} if it is a file, or true if {path} is a directory. 100 function M.read(path) 101 vim.validate('path', path, 'string') 102 local fullpath = vim.uv.fs_realpath(vim.fs.normalize(path)) 103 if not fullpath then 104 return nil 105 end 106 107 local trust = read_trust() 108 109 if trust[fullpath] == '!' then 110 -- File is denied 111 return nil 112 end 113 114 local contents, hash = compute_hash(fullpath, nil) 115 if not contents then 116 return nil 117 end 118 119 if trust[fullpath] == hash then 120 -- File already exists in trust database 121 return contents 122 end 123 124 local msg2 = ' To enable it, choose (v)iew then run `:trust`:' 125 local choices = '&ignore\n&view\n&deny' 126 if hash == 'directory' then 127 msg2 = ' DIRECTORY trust is decided only by name, not contents:' 128 choices = '&ignore\n&view\n&deny\n&allow' 129 end 130 131 -- File either does not exist in trust database or the hash does not match 132 local ok, result = pcall( 133 vim.fn.confirm, 134 string.format('exrc: Found untrusted code.%s\n%s', msg2, fullpath), 135 choices, 136 1 137 ) 138 139 if not ok and result ~= 'Keyboard interrupt' then 140 error(result) 141 elseif not ok or result == 0 or result == 1 then 142 -- Cancelled or ignored 143 return nil 144 elseif result == 2 then 145 -- View 146 vim.cmd('sview ' .. fullpath) 147 return nil 148 elseif result == 3 then 149 -- Deny 150 trust[fullpath] = '!' 151 contents = nil 152 elseif hash == 'directory' and result == 4 then 153 -- Allow 154 trust[fullpath] = hash 155 end 156 157 write_trust(trust) 158 159 return contents 160 end 161 162 --- @class vim.trust.opts 163 --- @inlinedoc 164 --- 165 --- - `'allow'` to add a file to the trust database and trust it, 166 --- - `'deny'` to add a file to the trust database and deny it, 167 --- - `'remove'` to remove file from the trust database 168 --- @field action 'allow'|'deny'|'remove' 169 --- 170 --- Path to a file to update. Mutually exclusive with {bufnr}. 171 --- @field path? string 172 --- Buffer number to update. Mutually exclusive with {path}. 173 --- @field bufnr? integer 174 175 --- Manage the trust database. 176 --- 177 --- The trust database is located at |$XDG_STATE_HOME|/nvim/trust. 178 --- 179 ---@since 11 180 ---@param opts vim.trust.opts 181 ---@return boolean success true if operation was successful 182 ---@return string msg full path if operation was successful, else error message 183 function M.trust(opts) 184 vim.validate('path', opts.path, 'string', true) 185 vim.validate('bufnr', opts.bufnr, 'number', true) 186 vim.validate('action', opts.action, function(m) 187 return m == 'allow' or m == 'deny' or m == 'remove' 188 end, [["allow" or "deny" or "remove"]]) 189 190 ---@cast opts vim.trust.opts 191 local path = opts.path 192 local bufnr = opts.bufnr 193 local action = opts.action 194 195 assert(not path or not bufnr, '"path" and "bufnr" are mutually exclusive') 196 197 local fullpath ---@type string? 198 if path then 199 fullpath = vim.uv.fs_realpath(vim.fs.normalize(path)) 200 elseif bufnr then 201 local bufname = vim.api.nvim_buf_get_name(bufnr) 202 if bufname == '' then 203 return false, 'buffer is not associated with a file' 204 end 205 fullpath = vim.uv.fs_realpath(vim.fs.normalize(bufname)) 206 else 207 error('one of "path" or "bufnr" is required') 208 end 209 210 if not fullpath then 211 return false, string.format('invalid path: %s', path) 212 end 213 214 local trust = read_trust() 215 216 if action == 'allow' then 217 local contents, hash = compute_hash(fullpath, bufnr) 218 if not contents then 219 return false, string.format('could not read path: %s', fullpath) 220 end 221 222 trust[fullpath] = hash 223 elseif action == 'deny' then 224 trust[fullpath] = '!' 225 elseif action == 'remove' then 226 trust[fullpath] = nil 227 end 228 229 write_trust(trust) 230 return true, fullpath 231 end 232 233 return M