neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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