* feat(ftp): add FTP/FTPS adapter via curl Problem: canola has no way to browse or edit files on FTP servers, despite the adapter system being designed for exactly this pattern. curl speaks FTP natively, including FTPS (FTP over TLS), and requires no new dependencies. Solution: implement `lua/oil/adapters/ftp.lua` with `oil-ftp://` and `oil-ftps://` schemes. Parses Unix and IIS LIST output, supports `size`, `mtime`, and `permissions` columns, and implements the full adapter API (list, read_file, write_file, render_action, perform_action). Same-host renames use RNFR/RNTO; cross-host and local↔FTP copies use curl download/upload through a tmpfile. Adds `extra_curl_args` config option and documents the adapter in `doc/oil.txt`. Based on: stevearc/oil.nvim#210 * docs(upstream): mark #210 fixed in #167 * fix(ftp): use python3 ftplib for control-channel FTP operations Problem: DELE, RMD, MKD, and RNFR/RNTO were implemented using curl --quote, which requires a subsequent LIST or STOR to trigger the FTP connection. That data-channel operation hangs on slow or busy servers, making every mutation appear stuck. Solution: replace the curl --quote approach with a python3 ftplib one-liner for all control-channel operations. ftplib executes DELE, RMD, MKD, RNFR/RNTO, and SITE CHMOD without opening a data channel, so they complete instantly. The curl wrapper is retained for LIST, read_file, and write_file, which genuinely need a data channel. * fix(ftp): use nil entry ID so cache assigns unique IDs Problem: `M.list` returned entries as `{0, name, type, meta}`. `cache.store_entry` only assigns a fresh ID when `entry[FIELD_ID] == nil`; passing 0 caused every entry to be stored as ID 0, all overwriting each other. `get_entry_by_id(0)` then always returned the last-stored entry, breaking navigation (always opened the same file), rename (wrong entry matched), and create (wrong diff). Solution: change the placeholder from 0 to nil, matching how `cache.create_entry` itself builds entries. * fix(ftp): use ftp.rename() for RNFR/RNTO and raw Python lines in ftpcmd Problem: `ftpcmd` wrapped every command in `ftp.voidcmd()`, which expects a final 2xx response. `RNFR` returns 350 (intermediate), so `voidcmd` raised an exception before `RNTO` was ever sent, causing every rename to fail with '350 Ready for destination name'. Solution: change `ftpcmd` to accept raw Python lines instead of FTP command strings, then use `ftp.rename(src, dst)` for the rename case. `ftplib.rename` handles the 350 intermediate response correctly internally. All other callers now wrap their FTP commands in `ftp.voidcmd()` explicitly. * fix(ftp): recursively delete directory contents before RMD Problem: FTP's RMD command fails with '550 Directory not empty' if the directory has any contents. Unlike the S3 adapter which uses `aws s3 rm --recursive`, FTP has no protocol-level recursive delete. Solution: emit a Python rmtree helper inside the ftpcmd script that walks the directory via MLSD, recursively deletes children (DELE for files, rmtree for subdirs), then sends RMD on the now-empty directory. * fix(ftp): give oil-ftps:// its own adapter name to prevent scheme clobbering Problem: both oil-ftp:// and oil-ftps:// mapped to the adapter name 'ftp', so config.adapter_to_scheme['ftp'] was set to whichever scheme pairs() iterated last — non-deterministic. init.lua uses adapter_to_scheme[adapter.name] to reconstruct the parent URL, so roughly half the time it injected 'oil-ftps://' into ftp:// buffer navigation, causing the ssl-reqd error on '-' press. Solution: register oil-ftps:// under adapter name 'ftps' via a one-line shim that returns require('oil.adapters.ftp'). Now adapter_to_scheme['ftp'] = 'oil-ftp://' and adapter_to_scheme['ftps'] = 'oil-ftps://' are both stable. * fix(ftp): percent-encode path in curl FTP URLs Problem: filenames containing spaces (or other URL-unsafe characters) caused curl to fail with "Unknown error" because the raw path was concatenated directly into the FTP URL. Solution: add `url_encode_path` to encode non-safe characters (excluding `/`) before building the curl URL in `curl_ftp_url`. * 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. * ci: format * fix(ftp): resolve LuaLS type warnings in `curl` wrapper and `parse_unix_list_line`
166 lines
5.6 KiB
Lua
166 lines
5.6 KiB
Lua
local ftp = require('oil.adapters.ftp')
|
|
|
|
describe('ftp adapter', function()
|
|
describe('parse_url', function()
|
|
it('parses minimal url', function()
|
|
local res = ftp.parse_url('oil-ftp://host/')
|
|
assert.equals('oil-ftp://', res.scheme)
|
|
assert.equals('host', res.host)
|
|
assert.equals('', res.path)
|
|
assert.equals(nil, res.user)
|
|
assert.equals(nil, res.password)
|
|
assert.equals(nil, res.port)
|
|
end)
|
|
|
|
it('parses url with user, password, port, and path', function()
|
|
local res = ftp.parse_url('oil-ftp://user:pass@host:2121/some/path/')
|
|
assert.equals('oil-ftp://', res.scheme)
|
|
assert.equals('host', res.host)
|
|
assert.equals('user', res.user)
|
|
assert.equals('pass', res.password)
|
|
assert.equals(2121, res.port)
|
|
assert.equals('some/path/', res.path)
|
|
end)
|
|
|
|
it('parses url with user only', function()
|
|
local res = ftp.parse_url('oil-ftp://user@host/path/')
|
|
assert.equals('user', res.user)
|
|
assert.equals(nil, res.password)
|
|
assert.equals(nil, res.port)
|
|
assert.equals('path/', res.path)
|
|
end)
|
|
|
|
it('parses oil-ftps:// scheme', function()
|
|
local res = ftp.parse_url('oil-ftps://host/path/')
|
|
assert.equals('oil-ftps://', res.scheme)
|
|
assert.equals('host', res.host)
|
|
end)
|
|
|
|
it('parses port without credentials', function()
|
|
local res = ftp.parse_url('oil-ftp://host:990/')
|
|
assert.equals('host', res.host)
|
|
assert.equals(990, res.port)
|
|
assert.equals(nil, res.user)
|
|
end)
|
|
end)
|
|
|
|
describe('get_parent', function()
|
|
it('goes up one directory', function()
|
|
assert.equals('oil-ftp://host/', ftp.get_parent('oil-ftp://host/subdir/'))
|
|
end)
|
|
|
|
it('goes up nested directories', function()
|
|
assert.equals(
|
|
'oil-ftp://user:pass@host:2121/a/b/',
|
|
ftp.get_parent('oil-ftp://user:pass@host:2121/a/b/c/')
|
|
)
|
|
end)
|
|
|
|
it('stays at root', function()
|
|
assert.equals('oil-ftp://host/', ftp.get_parent('oil-ftp://host/'))
|
|
end)
|
|
end)
|
|
|
|
describe('unix list line parsing', function()
|
|
it('parses a regular file', function()
|
|
local name, entry_type, meta =
|
|
ftp._parse_unix_list_line('-rw-r--r-- 1 user group 42 Jan 15 2024 hello.txt')
|
|
assert.equals('hello.txt', name)
|
|
assert.equals('file', entry_type)
|
|
assert.equals(42, meta.size)
|
|
assert.equals('number', type(meta.mtime))
|
|
assert.equals(420, meta.mode)
|
|
end)
|
|
|
|
it('parses a directory', function()
|
|
local name, entry_type, meta =
|
|
ftp._parse_unix_list_line('drwxr-xr-x 2 user group 4096 Mar 18 10:30 subdir')
|
|
assert.equals('subdir', name)
|
|
assert.equals('directory', entry_type)
|
|
assert.equals(4096, meta.size)
|
|
assert.equals('number', type(meta.mtime))
|
|
end)
|
|
|
|
it('parses a symlink', function()
|
|
local name, entry_type, meta =
|
|
ftp._parse_unix_list_line('lrwxrwxrwx 1 user group 8 Mar 18 10:30 link -> target')
|
|
assert.equals('link', name)
|
|
assert.equals('link', entry_type)
|
|
assert.equals('target', meta.link)
|
|
end)
|
|
|
|
it('parses a filename with spaces', function()
|
|
local name, entry_type, _ =
|
|
ftp._parse_unix_list_line('-rw-r--r-- 1 user group 100 Mar 15 09:00 my file.txt')
|
|
assert.equals('my file.txt', name)
|
|
assert.equals('file', entry_type)
|
|
end)
|
|
|
|
it('returns nil for unrecognised lines', function()
|
|
local name = ftp._parse_unix_list_line('total 42')
|
|
assert.equals(nil, name)
|
|
end)
|
|
end)
|
|
|
|
describe('iis list line parsing', function()
|
|
it('parses a directory', function()
|
|
local name, entry_type, _ =
|
|
ftp._parse_iis_list_line('01-14-24 09:27AM <DIR> dirname')
|
|
assert.equals('dirname', name)
|
|
assert.equals('directory', entry_type)
|
|
end)
|
|
|
|
it('parses a file', function()
|
|
local name, entry_type, meta =
|
|
ftp._parse_iis_list_line('01-14-24 09:27AM 12345 file.txt')
|
|
assert.equals('file.txt', name)
|
|
assert.equals('file', entry_type)
|
|
assert.equals(12345, meta.size)
|
|
end)
|
|
|
|
it('returns nil for unrecognised lines', function()
|
|
local name = ftp._parse_iis_list_line('drwxr-xr-x 2 user group 4096 Mar 18 10:30 subdir')
|
|
assert.equals(nil, name)
|
|
end)
|
|
end)
|
|
|
|
describe('curl_ftp_url', function()
|
|
it('always uses ftp:// scheme for oil-ftp://', function()
|
|
local url = ftp.parse_url('oil-ftp://host/path/')
|
|
assert(vim.startswith(ftp._curl_ftp_url(url), 'ftp://'))
|
|
end)
|
|
|
|
it('always uses ftp:// scheme for oil-ftps://', function()
|
|
local url = ftp.parse_url('oil-ftps://host/path/')
|
|
assert(vim.startswith(ftp._curl_ftp_url(url), 'ftp://'))
|
|
end)
|
|
|
|
it('encodes spaces in path', function()
|
|
local url = ftp.parse_url('oil-ftp://host/my path/')
|
|
assert.equals('ftp://host/my%20path/', ftp._curl_ftp_url(url))
|
|
end)
|
|
|
|
it('includes credentials and port', function()
|
|
local url = ftp.parse_url('oil-ftp://user:pass@host:2121/dir/')
|
|
assert.equals('ftp://user:pass@host:2121/dir/', ftp._curl_ftp_url(url))
|
|
end)
|
|
end)
|
|
|
|
describe('url_encode_path', function()
|
|
it('leaves safe characters unchanged', function()
|
|
assert.equals('hello.txt', ftp._url_encode_path('hello.txt'))
|
|
end)
|
|
|
|
it('encodes spaces', function()
|
|
assert.equals('a%20file.txt', ftp._url_encode_path('a file.txt'))
|
|
end)
|
|
|
|
it('preserves path separators', function()
|
|
assert.equals('dir/subdir/file.txt', ftp._url_encode_path('dir/subdir/file.txt'))
|
|
end)
|
|
|
|
it('encodes special characters', function()
|
|
assert.equals('hello%23world', ftp._url_encode_path('hello#world'))
|
|
end)
|
|
end)
|
|
end)
|