feat(ftp): add FTP/FTPS adapter via curl (#167)

* 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`
This commit is contained in:
Barrett Ruth 2026-03-17 23:47:20 -04:00 committed by GitHub
parent d67195b637
commit a410507846
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 931 additions and 24 deletions

View file

@ -5,30 +5,30 @@ issues against this fork.
## Upstream PRs
| PR | Description | Status |
| ----------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [#495](https://github.com/stevearc/oil.nvim/pull/495) | Cancel visual/operator-pending mode on close | cherry-picked |
| [#537](https://github.com/stevearc/oil.nvim/pull/537) | Configurable file/directory creation permissions | cherry-picked |
| [#618](https://github.com/stevearc/oil.nvim/pull/618) | Opt-in filetype detection for icons | cherry-picked |
| [#644](https://github.com/stevearc/oil.nvim/pull/644) | Pass entry to `is_hidden_file`/`is_always_hidden` | cherry-picked |
| [#697](https://github.com/stevearc/oil.nvim/pull/697) | Recipe for file extension column | cherry-picked |
| [#698](https://github.com/stevearc/oil.nvim/pull/698) | Executable file highlighting | cherry-picked |
| [#717](https://github.com/stevearc/oil.nvim/pull/717) | Add oil-git.nvim to extensions | cherry-picked |
| [#720](https://github.com/stevearc/oil.nvim/pull/720) | Gate `BufAdd` autocmd behind config check | cherry-picked |
| [#722](https://github.com/stevearc/oil.nvim/pull/722) | Fix freedesktop trash URL | cherry-picked |
| [#723](https://github.com/stevearc/oil.nvim/pull/723) | Emit `OilReadPost` event after render | cherry-picked |
| [#725](https://github.com/stevearc/oil.nvim/pull/725) | Normalize keymap keys before config merge | cherry-picked |
| [#727](https://github.com/stevearc/oil.nvim/pull/727) | Clarify `get_current_dir` nil + Telescope recipe | cherry-picked |
| [#739](https://github.com/stevearc/oil.nvim/pull/739) | macOS FreeDesktop trash recipe | cherry-picked |
| [#488](https://github.com/stevearc/oil.nvim/pull/488) | Parent directory in a split | not actionable — empty PR |
| [#493](https://github.com/stevearc/oil.nvim/pull/493) | UNC paths on Windows | not actionable — superseded by [#686](https://github.com/stevearc/oil.nvim/pull/686) |
| [#686](https://github.com/stevearc/oil.nvim/pull/686) | Windows path conversion fix | not actionable — Windows-only |
| [#735](https://github.com/stevearc/oil.nvim/pull/735) | gX opens external program with selection | not actionable — hardcoded Linux-only, incomplete |
| [#591](https://github.com/stevearc/oil.nvim/pull/591) | release-please changelog | not applicable |
| [#667](https://github.com/stevearc/oil.nvim/pull/667) | Virtual text columns + headers | consolidated into [#142](https://github.com/barrettruth/canola.nvim/issues/142) |
| [#708](https://github.com/stevearc/oil.nvim/pull/708) | Move file into new dir by renaming | consolidated into [#32](https://github.com/barrettruth/canola.nvim/issues/32) |
| PR | Description | Status |
| ----------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| [#495](https://github.com/stevearc/oil.nvim/pull/495) | Cancel visual/operator-pending mode on close | cherry-picked |
| [#537](https://github.com/stevearc/oil.nvim/pull/537) | Configurable file/directory creation permissions | cherry-picked |
| [#618](https://github.com/stevearc/oil.nvim/pull/618) | Opt-in filetype detection for icons | cherry-picked |
| [#644](https://github.com/stevearc/oil.nvim/pull/644) | Pass entry to `is_hidden_file`/`is_always_hidden` | cherry-picked |
| [#697](https://github.com/stevearc/oil.nvim/pull/697) | Recipe for file extension column | cherry-picked |
| [#698](https://github.com/stevearc/oil.nvim/pull/698) | Executable file highlighting | cherry-picked |
| [#717](https://github.com/stevearc/oil.nvim/pull/717) | Add oil-git.nvim to extensions | cherry-picked |
| [#720](https://github.com/stevearc/oil.nvim/pull/720) | Gate `BufAdd` autocmd behind config check | cherry-picked |
| [#722](https://github.com/stevearc/oil.nvim/pull/722) | Fix freedesktop trash URL | cherry-picked |
| [#723](https://github.com/stevearc/oil.nvim/pull/723) | Emit `OilReadPost` event after render | cherry-picked |
| [#725](https://github.com/stevearc/oil.nvim/pull/725) | Normalize keymap keys before config merge | cherry-picked |
| [#727](https://github.com/stevearc/oil.nvim/pull/727) | Clarify `get_current_dir` nil + Telescope recipe | cherry-picked |
| [#739](https://github.com/stevearc/oil.nvim/pull/739) | macOS FreeDesktop trash recipe | cherry-picked |
| [#488](https://github.com/stevearc/oil.nvim/pull/488) | Parent directory in a split | not actionable — empty PR |
| [#493](https://github.com/stevearc/oil.nvim/pull/493) | UNC paths on Windows | not actionable — superseded by [#686](https://github.com/stevearc/oil.nvim/pull/686) |
| [#686](https://github.com/stevearc/oil.nvim/pull/686) | Windows path conversion fix | not actionable — Windows-only |
| [#735](https://github.com/stevearc/oil.nvim/pull/735) | gX opens external program with selection | not actionable — hardcoded Linux-only, incomplete |
| [#591](https://github.com/stevearc/oil.nvim/pull/591) | release-please changelog | not applicable |
| [#667](https://github.com/stevearc/oil.nvim/pull/667) | Virtual text columns + headers | consolidated into [#142](https://github.com/barrettruth/canola.nvim/issues/142) |
| [#708](https://github.com/stevearc/oil.nvim/pull/708) | Move file into new dir by renaming | consolidated into [#32](https://github.com/barrettruth/canola.nvim/issues/32) |
| [#721](https://github.com/stevearc/oil.nvim/pull/721) | `create_hook` to populate file contents | not actionable — `OilFileCreated` event already covers the use case (see [#280](https://github.com/stevearc/oil.nvim/issues/280)) |
| [#728](https://github.com/stevearc/oil.nvim/pull/728) | `open_split` for opening oil in a split | deferred — tracked as [#2](https://github.com/barrettruth/canola.nvim/issues/2) |
| [#728](https://github.com/stevearc/oil.nvim/pull/728) | `open_split` for opening oil in a split | deferred — tracked as [#2](https://github.com/barrettruth/canola.nvim/issues/2) |
## Issues
@ -40,7 +40,7 @@ issues against this fork.
| [#156](https://github.com/stevearc/oil.nvim/issues/156) | Paste path of files into oil buffer | fixed — added `oil-recipe-paste-file-from-clipboard` |
| [#200](https://github.com/stevearc/oil.nvim/issues/200) | Highlights not working when opening a file | not actionable — cannot reproduce, nvim 0.9.4 |
| [#207](https://github.com/stevearc/oil.nvim/issues/207) | Suppress "no longer available" message | fixed — `cleanup_buffers_on_delete` option |
| [#210](https://github.com/stevearc/oil.nvim/issues/210) | FTP support | open |
| [#210](https://github.com/stevearc/oil.nvim/issues/210) | FTP support | fixed ([#167](https://github.com/barrettruth/canola.nvim/pull/167)) |
| [#213](https://github.com/stevearc/oil.nvim/issues/213) | Disable preview for large files | fixed ([#85](https://github.com/barrettruth/canola.nvim/pull/85)) |
| [#226](https://github.com/stevearc/oil.nvim/issues/226) | K8s/Docker adapter | not actionable — no demand |
| [#232](https://github.com/stevearc/oil.nvim/issues/232) | Cannot close last window | consolidated into [#149](https://github.com/barrettruth/canola.nvim/issues/149) |