A cross-platform terminal SFTP / SCP / FTP / FTPS client with a three-pane TUI, built using Claude.
- SFTP with password, SSH key, and encrypted-key (passphrase prompt) auth
- SCP as transparent SFTP — matches OpenSSH 9.0+ behavior, full feature parity
- FTP with anonymous and password auth
- FTPS with explicit TLS (RFC 4217) via rustls — pure Rust, no system TLS library needed
- ssh-agent auth on Unix (uses
$SSH_AUTH_SOCK); on Windows, the built-in OpenSSH agent (\\.\pipe\openssh-ssh-agent) is tried first, falling back to Pageant - Host-key verification for SFTP/SCP — unknown keys trigger an
interactive prompt (accept & save / trust once / reject); changed keys are
hard-rejected with a clear warning. Keys are stored in
~/.config/blink/known_hostsin standard OpenSSH format. - One-key disconnect, return to selector
- Three-pane TUI (local / remote, plus a switchable transfers/log panel)
- Recursive download and upload with parallel slot dispatch
- Rename, create directories, delete files, delete directories (recursive)
- Substring filter per pane (
/), persists across refresh - Refresh active pane (F5)
- Disconnect and return to the session selector (
Ctrl-X) - View text files inline (scrollable, line-numbered) — control and ANSI escape characters are stripped before display to prevent terminal injection; see Supported viewer formats for the recognised extensions
- View images via kitty graphics protocol, sixel, and iTerm2 inline images — auto-detected, aspect-preserving, terminal-cell-aware scaling
- Parallel slot dispatcher (configurable globally; per-session override)
- Live transfer strip with bytes, percentage, MB/s
- Pause / resume all transfers (
p) - Cancel individual in-flight transfers (
c) - Cancel whole batch for recursive transfers (
C) — aborts every active and queued job from the sameCtrl-D/Ctrl-U - Overwrite confirmation with three choices — overwrite all / skip conflicts / cancel — for both downloads and uploads, single-file or recursive
- Walk checkpointing — the transfer plan is written to disk before the
first job runs; each job is marked
in_progresswhen it starts anddonewhen it finishes. If the session is interrupted, pressr(resume downloads) orR(resume uploads) in the Transfers pane to re-queue only the jobs that didn't complete. Useblink checkpointsto inspect pending checkpoints from the command line.
- Saved sessions in INI files; create, edit, delete from the selector
- Per-session overrides for
local_dir(with~expansion),remote_dir,parallel_downloads,accept_invalid_certs, theme - Passwords are never persisted; prompted at connect time
- Session URL parser (
sftp://user@host:port/path) for ad-hoc connects
Seven built-in themes — dracula, aura, nord, solarized-dark,
solarized-osaka, tokyo-night, cyberpunk-neon — user-supplied themes
drop in as INI files in the themes directory. t cycles through
available themes from either the session selector or the main view, with
the choice persisted to config.ini automatically.
- Rust 1.85+ with the 2024 edition
That's it. Every TLS-using dependency is pure Rust (rustls), so there's no
need for libssl-dev or any other system TLS library on any platform.
cargo build --releaseThe binary lands in target/release/blink.
- Linux and Windows are the two officially-tested targets
- macOS should work but hasn't seen as much exercise
You can produce a Windows binary without leaving your Linux box. There are two routes; pick based on what's already on your system.
cargo-xwin downloads the
Microsoft CRT and Windows SDK on first use, so you don't need Wine or a
licensed Visual Studio. Best route on a clean Linux box.
# One-time setup
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
# Build
cargo xwin build --release --target x86_64-pc-windows-msvcOutput: target/x86_64-pc-windows-msvc/release/blink.exe.
The first build downloads ~700 MB of SDK headers and libs into
~/.cache/cargo-xwin/; subsequent builds reuse the cache.
If you'd rather use the GNU toolchain (no Microsoft CRT), MinGW-w64 works for blink because nothing in the dependency tree needs MSVC-only features.
# Debian / Ubuntu
sudo apt install mingw-w64
# Fedora / RHEL
sudo dnf install mingw64-gcc
rustup target add x86_64-pc-windows-gnu
cargo build --release --target x86_64-pc-windows-gnuOutput: target/x86_64-pc-windows-gnu/release/blink.exe.
- The Windows binary is a real PE32+ executable; it runs natively on Windows 10 / 11 with no runtime dependencies beyond the standard Microsoft Visual C++ Runtime (already present on every modern Windows).
- For ARM64 Windows, swap
x86_64foraarch64in either route.
# Launch into the session selector
blink
# Connect directly without a saved session
blink connect sftp://user@host:22
# Open a saved session by name
blink open production
# Print built-in themes
blink themes
# List saved sessions
blink sessions
# Show any interrupted batch-transfer checkpoints
blink checkpoints
# Remove completed and orphaned checkpoint files
blink checkpoints --clean
# Remove all checkpoint files unconditionally
blink checkpoints --forceblink open exits with an error if the session name is not found.
blink connect accepts any URL in the form protocol://[user@]host[:port][/path]
where protocol is one of sftp, scp, ftp, or ftps. Both commands
prompt for a password if the session uses password auth, or go straight to
the Connection screen for key and agent auth.
Files live in platform-appropriate locations:
| Path | Linux | macOS | Windows |
|---|---|---|---|
| Global config | $XDG_CONFIG_HOME/blink/config.ini (else ~/.config/blink/) |
~/Library/Application Support/blink/config.ini |
%USERPROFILE%\Documents\blink\config.ini |
| Sessions | ~/.config/blink/sessions/ |
~/Library/Application Support/blink/sessions/ |
%USERPROFILE%\Documents\blink\sessions\ |
| Themes | ~/.config/blink/themes/ |
~/Library/Application Support/blink/themes/ |
%USERPROFILE%\Documents\blink\themes\ |
| Known hosts | ~/.config/blink/known_hosts |
~/Library/Application Support/blink/known_hosts |
%USERPROFILE%\Documents\blink\known_hosts |
| Checkpoints | ~/.config/blink/checkpoints/ |
~/Library/Application Support/blink/checkpoints/ |
%USERPROFILE%\Documents\blink\checkpoints\ |
macOS honours XDG_CONFIG_HOME if explicitly set; otherwise it follows the
platform's Library/Application Support convention.
blink maintains its own known-hosts file separate from ~/.ssh/known_hosts.
The on-disk format matches OpenSSH: one entry per line, hostname key-type base64-key. The hostname is stored as a bare lowercased host for the
default SSH port and [host]:port for any other port — exactly as OpenSSH
writes it. Hashed (|1|salt|hash) entries from HashKnownHosts=yes aren't
supported; blink only reads files it wrote itself. Files written by older
blink versions in the host:port form are still accepted on lookup, so
upgrades don't force re-verification.
Match semantics also follow OpenSSH: a host with both an ed25519 and an rsa entry is normal multi-algorithm behaviour, not a mismatch. The "changed key" hard-reject fires only when the same keytype on file has a different key blob.
When connecting via SFTP or SCP for the first time, blink shows a prompt with the server's SHA-256 fingerprint and three choices:
| Key | Action |
|---|---|
y |
Accept and save to known_hosts (future connects are silent) |
t |
Trust once — accept for this session only, don't save |
n / Esc |
Reject — abort the connection |
If a host's key changes after being saved, blink hard-rejects the
connection and shows a warning screen that only Enter / Esc / q
dismisses (so a held key can't blow past the warning). To reconnect after
a legitimate key rotation, remove the old entry from the known-hosts file
manually and reconnect.
Concurrent appends from two blink processes accepting the same new host are serialised through an exclusive advisory file lock, so two parallel connects can't write duplicate or interleaved lines.
[general]
theme = dracula ; one of the seven built-ins, or a user theme
parallel_downloads = 2 ; default; sessions can override (max 10)
confirm_quit = true
[terminal]
image_preview = auto ; auto | kitty | sixel | iterm2 | none[session]
name = production
protocol = sftp ; sftp | scp | ftp | ftps
host = prod.example.com
port = 22
username = me
remote_dir = /var/www
local_dir = ~/work/prod ; optional override; ~ expands
[auth]
method = key ; password | key | agent
key_path = ~/.ssh/id_ed25519
[transfer]
parallel_downloads = 4 ; optional override
[appearance]
theme = tokyo-night ; optional override
[tls]
accept_invalid_certs = false ; FTPS only; default false. true switches
; from CA-chain validation to TOFU pinning:
; hostname is still verified, the handshake
; signature is still verified, and the
; cert SHA-256 is pinned on first connect.
cert_sha256 = abc123… ; auto-populated by the TOFU pin above; do
; not edit by hand. Clear it (delete the
; line) if the legitimate cert rotates.Passwords are never written to disk; in-memory copies are wiped with
zeroize when the connected session ends.
[theme]
name = my-theme
[colors]
bg = #1a1b26
fg = #c0caf5
dim = #565f89
cursor_bg = #282a36
border_active = #bb9af7
border_inactive = #292e42
accent = #f7768e
directory = #7dcfff
image = #f7768e
selected = #e0af68
success = #9ece6a
warning = #ff9e64
error = #f7768eThe full list lives in the in-app help overlay (?). Highlights:
| Key | Action |
|---|---|
tab / S-tab |
cycle active pane (Local → Remote → Transfers → Log) |
↑ / ↓ |
move cursor |
↵ |
open file or enter directory |
backspace |
go up to parent directory |
space |
select / deselect |
^d |
download selected items |
^u |
upload selected items |
v |
view image or text |
/ |
filter current pane |
F5 |
refresh active pane |
F2 |
rename (remote pane) |
F7 |
create new remote directory |
S-del / D |
delete file or folder (remote pane) |
^s |
save current session |
^x |
disconnect (return to selector) |
t |
cycle theme |
c |
cancel selected transfer (Transfers pane) |
C |
cancel whole batch (Transfers pane) |
r |
resume interrupted download batch (Transfers pane) |
R |
resume interrupted upload batch (Transfers pane) |
p |
pause / resume all transfers |
? |
toggle help |
q / esc |
quit (with confirmation) |
In the session selector: n new, e edit, d delete, t cycle theme.
v opens the in-app viewer for the cursor item. Whether a file is recognised
depends on its extension (or, for a few well-known names, the bare filename).
False negatives just mean you have to download to read it; nothing is
guessed by content sniffing.
.png, .jpg / .jpeg, .gif, .webp, .bmp
Display caps at 10 MB to keep encode time bounded.
Display caps at 1 MB.
Bare filenames recognised without an extension:
README, LICENSE / LICENCE, Makefile, Dockerfile, CHANGELOG,
AUTHORS, CONTRIBUTORS, TODO, NOTICE
Recognised extensions, by category:
| Category | Extensions |
|---|---|
| Generic | txt, md, rst, log |
| Config | ini, conf, cfg, config, env, gitignore, gitattributes, editorconfig |
| Data | json, yaml, yml, toml, xml, csv, tsv |
| Web | html, htm, css, scss, sass, less |
| JavaScript | js, mjs, cjs, ts, jsx, tsx |
| Systems | rs, c, h, cpp, cxx, cc, hpp, go |
| Scripting | py, rb, lua, pl, r, php |
| JVM/.NET | java, kt, swift, cs |
| Shell | sh, bash, zsh, fish, ps1, bat |
| Database | sql |
| Patches | diff, patch |
| Release | nfo |
Text decoding: NFO files are decoded as CP437 (DOS codepage 437), preserving box-drawing characters. All other text files are decoded as UTF-8 (lossy — unrecognised byte sequences render as replacement characters rather than failing the viewer).
If you'd like another extension recognised, the allowlist is one match arm
in src/preview.rs::is_viewable_text.
src/
├── main.rs entrypoint, CLI parsing, terminal lifecycle
├── error.rs one error enum to rule them all
├── paths.rs platform config / session / theme / checkpoint dirs; sync_parent_dir helper
├── config.rs global config.ini load / save (preserves unknown keys)
├── session.rs per-session .ini load / save / list / URL parser
├── theme.rs theme model + 7 built-ins + file loader
├── checkpoint.rs walk-plan checkpointing: persist, update, remove batch state; debounced fsync writes
├── known_hosts.rs SSH host-key store: check / append / remove, OpenSSH-style matching, file lock
├── highlight.rs syntax highlighter for the text viewer (single-pass, zero deps)
├── transport/ connection layer
│ ├── mod.rs Transport trait + factory + Connected struct + part_path helper
│ ├── sftp.rs SFTP via russh + russh-sftp (SSH keepalive, rsa-sha2-512)
│ ├── scp.rs transparent SFTP wrapper (matches OpenSSH 9.0+)
│ ├── ftp.rs FTP via suppaftp tokio backend
│ ├── ftps.rs FTPS via suppaftp + rustls; pinning verifier (hostname + signature + cert pin)
│ └── ftp_impl.rs shared macro that generates the Transport impl for FTP and FTPS
├── transfer.rs TransferManager: queue, state, progress events; MAX_QUEUED_JOBS cap
├── transfer/
│ └── dispatcher.rs parallel slot dispatcher; per-job worker tasks; zeroized password
├── preview.rs kitty / sixel / iterm2 backends; FileViewKind classification; CP437 NFO decoder
└── tui/
├── mod.rs terminal init / restore + top-level run wrappers
├── event.rs keyboard / app-event multiplexer
├── plan.rs recursive walk planner: PlannedJob, WalkResult, walk_remote / walk_local, conflict probes
├── state.rs per-modal / per-pane / per-form state types (PaneState, Viewer, PendingHostKey, EditSessionForm, …)
├── views.rs render functions per screen / overlay
├── widgets.rs file pane, bottom panel, status bar, transfer strip
└── app/ the App state machine, split by responsibility
├── mod.rs struct + new / with_session / run + draw + handle_key dispatcher + connect / disconnect / push_log
├── handlers.rs per-screen key handlers (one impl App block, all handle_* methods)
├── events.rs handle_app_event + handle_transfer_event (background-task drain)
├── checkpoint_glue.rs dispatch_plan / resume_walk / discard_active_checkpoint
├── transfers.rs orchestration: enqueue / walk-spawn / confirm overwrite → dispatch
├── actions.rs modal openers + submitters + async kickoffs (rename / mkdir / delete / edit-session / search / save-session)
├── panes.rs pane navigation, cursor, refresh (refresh_local_pane runs on the blocking pool)
├── controls.rs cancel / pause / theme controls + active_jobs snapshot
└── viewer.rs file viewer (text + image), tokenisation cache, after_draw image-redraw hook
The trait boundaries that matter for extension:
Transportintransport/mod.rs— implement this once per protocol. Adding a new protocol is one new file plus one match arm intransport::open.PreviewBackendinpreview.rs— implement once per terminal-graphics protocol.Themeis just a struct loaded from INI; new themes drop in as files, no code changes needed.
The transfer layer has its own clean seam: TransferManager owns the queue
and progress channel; Dispatcher is a separate task that pulls pending
jobs and runs them against Box<dyn Transport>. The two communicate only
through the manager's public methods, so the dispatch policy can be swapped
or extended without touching the queue.
blink connects to remote servers over the open internet and renders server-supplied content (filenames, error messages, file previews) in your terminal. The following properties are enforced in the current codebase.
- SFTP / SCP host-key verification — unknown keys trigger an interactive
prompt; a changed key is a hard rejection with a warning screen that
only
Enter/Esc/qdismisses. Keys are stored in OpenSSH format. Matching follows OpenSSH(host, keytype)semantics: multi-algorithm hosts (an ed25519 and an rsa entry) coexist normally; "changed key" only fires when the same keytype has a different blob. - SFTP RSA auth uses rsa-sha2-512. ssh-rsa with SHA-1 has been disabled by default in OpenSSH 8.8+ (September 2021); blink negotiates the modern hash so RSA users don't get an opaque "rejected by server" against any current OpenSSH.
- SSH keepalive at 30 s × 3. An authenticated session with a dead TCP underneath tears down in ~90 s instead of waiting on the OS keepalive (which can run into minutes).
- FTPS — explicit TLS only (RFC 4217), verified against the Mozilla CA bundle via rustls (pure Rust, no system OpenSSL).
- FTPS hostname + signature verification are mandatory. Even when
accept_invalid_certs = truebypasses CA-chain trust, the cert's SAN must still match the configured hostname and the handshake signature must still verify against the cert's public key. The flag also enables TOFU pinning: the leaf cert SHA-256 is recorded in the session on the first connect; subsequent connects require an exact match. - 30-second connect timeout — applied to both the primary connection and every parallel worker connection. A server that accepts the TCP socket but stalls the handshake cannot pin connections indefinitely.
All server-controlled strings pass through a sanitizer that replaces control characters (U+0000–U+001F and U+007F–U+009F, covering ESC and all ANSI sequence starters) with spaces before being stored or rendered:
- Remote directory-entry names (
list()in every transport) - SSH key-type strings and host-key fingerprints
- Error messages from transport layers
- Text file content in the viewer (tabs preserved; no length cap beyond the 10 MB transport read limit)
- Remote-to-local path traversal — entry names containing
/,\,\0, or equal to../.are rejected beforePath::joinin download paths and recursive walks (safe_local_name()). - Remote path injection —
join_remote()strips leading/from server-supplied names and rejects any..component, preventing a server from escaping the working directory via path construction. - Recursive walks skip symlinks by default. A server-side symlink
named
passwdpointing at/etc/passwdwon't get fetched into the user's destination tree, and an A→B→A symlink cycle can't loop the walker. Single-fileViewof a symlink still works — that's an explicit per-file action. - SFTP recursive delete unlinks symlinks rather than following them.
Some SFTP servers report symlink-to-directory entries with both
is_dirandis_symlinkset; a recursivedelete_dir(true)that followed those could delete files outside the chosen subtree. - Password-in-URL is rejected.
sftp://alice:hunter2@host/errors with a pointer at the interactive prompt rather than smuggling the password into the username field and into shell history.
| Resource | Limit |
|---|---|
| Text file preview read | 1 MB (at preview detection; 10 MB at transport) |
| Image file preview read | 10 MB (at preview detection and transport) |
| Image decoder pre-decode dimension cap | 4096 × 4096 px |
| Image decoder max allocation | 128 MiB (enforced before full pixel-buffer alloc) |
| Transfer job queue | 100,000 jobs |
| Recursive walk plan | 100,000 jobs (bails before materialising the whole tree) |
| Error string length | 512 characters |
| Session / config / theme files | 64 KiB each |
| Checkpoint files | 10 MiB |
| Known-hosts file | 1 MiB |
Transport-layer read caps are enforced independently of server-reported
file sizes, so a server that lies in its directory listing cannot bypass
them. The image decoder limit is set via image::Limits so the decoder
refuses oversized declarations on the header rather than after the full
RGBA buffer is already allocated.
- Passwords and SSH key passphrases are held in memory only for the duration of the connected session and are never written to disk.
- In-memory copies are wrapped in
zeroize::Zeroizing<String>so the underlying allocation is wiped when the credential is dropped (cleared on disconnect / quit / connect failure). A long-running blink process doesn't leave the credential greppable in core dumps after the auth window has closed. - Each parallel worker slot opens its own authenticated connection and receives the cached credentials; no shared state crosses task boundaries.
- Session, config, and checkpoint files are written with the full
atomic-and-durable pattern: tempfile →
sync_all→ rename →sync_allon the parent directory. Without the syncs, a power loss between rename and the filesystem journal commit can leave a zero-byte file or roll the rename back. (Unix only; on Windows the filesystem journals rename through its own ordering rules.) - Checkpoint writes during a hot transfer batch are debounced at 250 ms so a 100k-job batch doesn't generate ~200k full plan rewrites. Any lost mark on a crash just causes the affected job to be re-queued on resume — never silently skipped.
- Downloads write to a
<local>.partsibling and rename onto the final name only afterflush+sync_all. The user's existing file (if any) isn't truncated until the new download has fsynced cleanly. - Config directories are created with mode 0700 on Unix (not world-readable).
Config::saveround-trips unknown INI keys, so hand-edited or forward-compat options aren't silently dropped on a theme cycle.- Path-traversal and null-byte validation is applied when loading session and theme names from disk.
A few things worth knowing before you use this in anger:
- FTPS is explicit-only. The
AUTH TLSupgrade path (RFC 4217) is what every modern server speaks. Implicit FTPS on the deprecated port 990 is not supported. If you have a server that only does implicit-mode, you'd need a different connect path; thetransport/ftps.rsseam is small. - FTPS uses the embedded Mozilla CA bundle (
webpki-roots) for trust anchors rather than the system trust store. Self-signed certs and privately-rooted CAs aren't trusted by default. The per-sessionaccept_invalid_certstoggle (visible in the edit-session form) switches from CA-chain trust to TOFU pinning: the cert hash is recorded on the first connect and subsequent connects require an exact match. Hostname binding and handshake-signature verification stay on in both modes. There is no option to add a custom CA root without recompiling. - Passwords are held in memory for the duration of the connected session, but the allocation is zeroised on drop. Each parallel transfer slot opens its own connection, so the dispatcher needs credentials to handshake each one. If that's not acceptable for your threat model, use SSH key auth or ssh-agent instead — the key file (or the agent's identity store) stays put and no in-memory copy of the secret is needed.
- Cancellation cascades at both the single-job and batch level.
ccancels the selected transfer;Ccancels every active and queued job in the same recursive batch. There is no partial-tree cancel (e.g. cancelling only a specific subdirectory within a larger walk). - Walk checkpoints survive a clean exit and most hard kills. The
initial plan is written with
flush()(synchronous) before any job runs. Per-job state changes are debounced at 250 ms — a crash within that window leaves the affected job in its previous state on resume, which is the same safe outcome as a crash mid-transfer (Pending → re-queued, InProgress → re-queued, Done → re-run). Partial downloads live at<name>.part; the final name is only created via rename after fsync.mkdiris idempotent on the remote side, so re-runs are safe across the board. - Transfers don't auto-refresh the local pane. Use F5 to refresh after downloads complete.
MIT. See LICENSE for the full text.
blink is built on the following open-source libraries. Each is used as an unmodified dependency; their licenses apply to their respective source code and do not affect blink's MIT license except where noted.
| Crate | Author(s) | Use in blink |
|---|---|---|
| ratatui | ratatui contributors | TUI layout and rendering |
| crossterm | TimonPost et al. | Cross-platform terminal I/O |
| tokio | Tokio contributors | Async runtime |
| tokio-util | Tokio contributors | Async I/O utilities |
| tracing | Tokio contributors | Structured logging |
| tracing-subscriber | Tokio contributors | Log sink / filter |
| bytes | Tokio contributors | Byte buffer utilities |
| rust-ini | Y. T. | INI config parser |
| icy_sixel | Mike Krüger | Sixel image encoding |
| parking_lot | Amanieu d'Antras | Faster synchronisation primitives |
| Crate | Author(s) | Use in blink |
|---|---|---|
| async-trait | David Tolnay | Async trait support |
| futures | Alex Crichton et al. | Future combinators |
| suppaftp | Christian Visintin | FTP / FTPS client |
| tokio-rustls | rustls contributors | Async TLS via rustls |
| serde | David Tolnay, Erick Tryzelaar | Serialisation framework |
| serde_json | David Tolnay, Erick Tryzelaar | JSON serialisation (checkpoints) |
| directories | Simon Ochsenreither | Platform config-dir paths |
| clap | clap contributors | CLI argument parsing |
| thiserror | David Tolnay | Error derive macro |
| anyhow | David Tolnay | Error context chaining |
| image | image-rs contributors | Image decoding (PNG, JPEG, GIF, WebP) |
| base64 | Marshall Pierce et al. | Base64 encoding for image preview |
| chrono | chronotope contributors | Date / time formatting |
| sha2 | RustCrypto contributors | SHA-256 for known-hosts disambiguation and FTPS cert pins |
| zeroize | RustCrypto contributors | Wipe in-memory passwords on drop |
| fs4 | fs4 contributors | Cross-platform advisory file locks (known_hosts append) |
| Crate | Author(s) | Use in blink |
|---|---|---|
| russh | Eugeny, Pierre-Étienne Meunier | SSH transport (SFTP / SCP) |
| russh-sftp | Eugeny | SFTP protocol layer |
| Crate | Author(s) | Use in blink |
|---|---|---|
| webpki-roots | Mozilla / rustls contributors | Mozilla CA root certificates for FTPS |
