fix(ftp): fix STARTTLS, error visibility, and robustness

Problem: `curl_ftp_url` emitted `ftps://` (implicit TLS) for
`oil-ftps://` URLs, causing listing to fail against STARTTLS servers
while Python mutations worked — the two paths spoke different TLS
modes. curl's `-s` flag silenced all error output, producing "Unknown
error" on any curl failure. File creation used `/dev/null` (breaks on
Windows, diverges from S3). The Python TLS context didn't honour
`--insecure`/`-k` from `extra_curl_args`. Deleting non-empty dirs on
servers without MLSD gave a cryptic `500 Unknown command`.

Solution: Always emit `ftp://` in `curl_ftp_url`; TLS is enforced
solely via `--ssl-reqd`, making STARTTLS consistent between curl and
Python. Add `-S` to expose curl errors. Replace `/dev/null` with
`curl -T -` + `stdin='null'` (matches `s3fs` pattern). Mirror
`--insecure`/`-k` into the Python SSL context. Wrap `mlsd()` in
try/except with a clear actionable message. Add `spec/ftp_spec.lua`
with 28 unit tests covering URL parsing, list parsing, and curl URL
building. Update `doc/oil.txt` to document STARTTLS and MLSD.
This commit is contained in:
Barrett Ruth 2026-03-17 23:41:54 -04:00
parent 0af52a0f6d
commit 27544d73e7
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
3 changed files with 205 additions and 14 deletions

View file

@ -78,16 +78,17 @@ end
---@param s string
---@return string
local function url_encode_path(s)
return (s:gsub('[^A-Za-z0-9%-._~:/]', function(c)
return string.format('%%%02X', c:byte())
end))
return (
s:gsub('[^A-Za-z0-9%-._~:/]', function(c)
return string.format('%%%02X', c:byte())
end)
)
end
---@param url oil.ftpUrl
---@return string
local function curl_ftp_url(url)
local scheme = url.scheme == 'oil-ftps://' and 'ftps://' or 'ftp://'
local pieces = { scheme }
local pieces = { 'ftp://' }
if url.user then
table.insert(pieces, url.user)
if url.password then
@ -112,8 +113,14 @@ local function ftpcmd(url, py_lines, cb)
local lines = {}
local use_tls = url.scheme == 'oil-ftps://'
if use_tls then
local insecure = vim.tbl_contains(config.extra_curl_args, '--insecure')
or vim.tbl_contains(config.extra_curl_args, '-k')
table.insert(lines, 'import ftplib, ssl')
table.insert(lines, 'ctx = ssl.create_default_context()')
if insecure then
table.insert(lines, 'ctx.check_hostname = False')
table.insert(lines, 'ctx.verify_mode = ssl.CERT_NONE')
end
table.insert(lines, 'ftp = ftplib.FTP_TLS(context=ctx)')
else
table.insert(lines, 'import ftplib')
@ -154,13 +161,18 @@ end
---@param url oil.ftpUrl
---@param extra_args string[]
---@param cb fun(err: nil|string, output: nil|string[])
local function curl(url, extra_args, cb)
local cmd = { 'curl', '-s', '--netrc-optional' }
---@param opts table|fun(err: nil|string, output: nil|string[])
---@param cb? fun(err: nil|string, output: nil|string[])
local function curl(url, extra_args, opts, cb)
if not cb then
cb = opts
opts = {}
end
local cmd = { 'curl', '-sS', '--netrc-optional' }
vim.list_extend(cmd, ssl_args(url))
vim.list_extend(cmd, config.extra_curl_args)
vim.list_extend(cmd, extra_args)
shell.run(cmd, cb)
shell.run(cmd, opts, cb)
end
---@param url oil.ftpUrl
@ -498,7 +510,7 @@ M.perform_action = function(action, cb)
elseif action.entry_type == 'link' then
cb('FTP does not support symbolic links')
else
curl(res, { '-T', '/dev/null', curl_ftp_url(res) }, cb)
curl(res, { '-T', '-', curl_ftp_url(res) }, { stdin = 'null' }, cb)
end
elseif action.type == 'delete' then
local res = M.parse_url(action.url)
@ -506,7 +518,12 @@ M.perform_action = function(action, cb)
if action.entry_type == 'directory' then
ftpcmd(res, {
'def rmtree(f, p):',
' for name, facts in f.mlsd(p):',
' try:',
' entries = list(f.mlsd(p))',
' except ftplib.error_perm as e:',
' if "500" in str(e) or "502" in str(e): import sys; sys.exit("Server does not support MLSD; cannot recursively delete non-empty directories")',
' raise',
' for name, facts in entries:',
' if name in (".", ".."): continue',
' child = p.rstrip("/") + "/" + name',
' if facts["type"] == "dir": rmtree(f, child)',
@ -654,4 +671,9 @@ M.write_file = function(bufnr)
end)
end
M._parse_unix_list_line = parse_unix_list_line
M._parse_iis_list_line = parse_iis_list_line
M._url_encode_path = url_encode_path
M._curl_ftp_url = curl_ftp_url
return M