# mftp

**High-throughput file transfer over high-latency links.**

mftp is built for the scenarios where `scp` crawls: satellite uplinks, intercontinental hops, anything where bandwidth × delay product is large. It multiplexes a single file across parallel QUIC streams, adapts chunk size and stream count to measured RTT, and compresses on the fly — while keeping the UX as simple as `scp`.

```
mftp send dataset.tar.gz user@remote-host:/data/
```

In SSH mode, mftp launches the receiver automatically over your existing SSH session. The primary transfer uses QUIC or TCP+TLS (both require an open port on the receiver); if those ports are blocked, mftp falls back to parallel SFTP through port 22 with no extra configuration.

---

## Features

| | |
|---|---|
| **QUIC transport** | Parallel streams over a single connection; no TCP head-of-line blocking |
| **BBR congestion control** | Measures bandwidth and RTT directly; avoids CUBIC's sawtooth pattern on lossy/high-latency links |
| **Auto TCP+TLS fallback** | If UDP is blocked, retries transparently over TCP+TLS; also auto-switches on LAN/datacenter links (RTT ≤ 15 ms) where kernel TCP beats QUIC |
| **SSH-assisted launch** | Spawns the receiver on the remote via SSH — no manual setup |
| **SFTP fallback** | If both QUIC and TCP+TLS are blocked, falls back to N parallel SFTP connections through port 22 — only this path requires no open port beyond SSH. **Significantly slower** (~22–32 MiB/s cap) due to SSH SFTP protocol overhead |
| **Adaptive compression** | Per-chunk zstd; skips chunks that don't compress (already-compressed formats auto-detected) |
| **Reed-Solomon FEC** | Optional parity shards (`--fec DATA:PARITY`). Note: does *not* improve throughput over QUIC (which already recovers loss) — see [Reed-Solomon FEC](#reed-solomon-fec) |
| **End-to-end integrity** | BLAKE3 per chunk (raw bytes) + full-file BLAKE3 verified on arrival |
| **RTT-aware negotiation** | Stream count and chunk size auto-tuned from measured round-trip time |
| **Resumable transfers** | Crash-safe bit-vector tracks received chunks; transfers continue where they left off |
| **TOFU authentication** | Self-signed certs; fingerprint confirmed once per session; `--trust` pins it for scripted use |

---

## Quick start

### Install

**Download a prebuilt binary** — latest release: **[v0.1.130](https://github.com/OmarHermannsson/mftp/releases/latest)** (Linux x86_64, macOS Intel + Apple Silicon, Windows x86_64; each with a `.sha256`):

```sh
# Linux x86_64 — verify the checksum, then install
curl -LO https://github.com/OmarHermannsson/mftp/releases/latest/download/mftp-linux-x86_64
curl -LO https://github.com/OmarHermannsson/mftp/releases/latest/download/mftp-linux-x86_64.sha256
sha256sum -c mftp-linux-x86_64.sha256
chmod +x mftp-linux-x86_64 && sudo mv mftp-linux-x86_64 /usr/local/bin/mftp
```

For macOS/Windows, grab the matching asset (`mftp-macos-aarch64`, `mftp-macos-x86_64`, `mftp-windows-x86_64.exe`) from the [releases page](https://github.com/OmarHermannsson/mftp/releases/latest).

Or with Cargo:

```sh
cargo install mftp
```

Or from git:

```sh
cargo install --git https://github.com/OmarHermannsson/mftp
```

Or build from source:

```sh
git clone https://github.com/OmarHermannsson/mftp
cd mftp
cargo build --release
# binary is at target/release/mftp
```

### Send a file (SSH mode)

```sh
# mftp SSHes to the remote, starts the receiver, transfers, and cleans up
mftp send bigfile.tar.gz user@remote-host:/data/landing/
```

mftp connects via your existing SSH credentials. The receiver is started automatically and exits when the transfer completes. It tries QUIC first, then TCP+TLS, then SFTP — falling back automatically if the direct transfer ports are blocked.

### Send a file (manual receiver)

On the receiver:
```sh
mftp receive --output-dir /data/landing
# Prints: Listening on 0.0.0.0:7777 (QUIC + TCP+TLS, auto-fallback)
# Prints: Certificate fingerprint: a3f9...
```

On the sender:
```sh
mftp send bigfile.tar.gz remote-host:7777 --trust a3f9...
```

The receiver port (7777 by default) must be reachable: UDP for QUIC, TCP for TCP+TLS, or both. There is no SFTP fallback in direct `host:port` mode.

### Examples by link type

mftp auto-tunes for the measured RTT, so the defaults are usually right. These show the flags worth setting when you know the link in advance.

**Satellite / very high latency (RTT ≳ 300 ms, some loss).**
The defaults are right: QUIC shines on long fat pipes and recovers loss via its own
retransmission. Just let it run:

```sh
mftp send dataset.tar user@ground-station:/data/
```

> **Don't reach for `--fec` here.** Benchmarking up to 30% loss shows FEC does *not*
> improve throughput over QUIC — QUIC already retransmits lost packets reliably, so the
> parity shards are pure overhead competing for the loss-limited link. See
> [Reed-Solomon FEC](#reed-solomon-fec).

**Intercontinental fibre (RTT ~150 ms, low loss).**
The defaults are tuned for exactly this. Pin a higher stream count only if you have spare CPU and the single-flow rate is capped by per-stream flow control:

```sh
mftp send dataset.tar user@remote:/data/ -n 16
```

**LAN / datacenter (RTT ≤ 15 ms).**
mftp automatically prefers TCP+TLS below `--tcp-below-rtt` (15 ms) since QUIC's ramp loses to TCP on fast links. Compression usually just burns CPU on a fast LAN, so disable it for already-incompressible data:

```sh
mftp send disk.img user@nas:/pool/ --no-compress
# force the transport explicitly if RTT estimation is unreliable:
mftp send disk.img user@nas:/pool/ --transport tcp --no-compress
```

**Sending a whole directory** (any link): add `-r`, and `--preserve` to keep mtimes and permissions:

```sh
mftp send ./project user@remote:/backups/ -r --preserve
```

---

## Usage

### `mftp send`

```
mftp send [OPTIONS] <FILE> <DESTINATION>
```

`DESTINATION` is either:
- `host:port` — connect to an already-running `mftp receive`. The port must be reachable (UDP for QUIC, TCP for TCP+TLS).
- `[user@]host:/remote/path` — launch the receiver via SSH (recommended). Falls back through QUIC → TCP+TLS → SFTP automatically.

```
Options:
  --trust <FINGERPRINT>    Pin the receiver's SHA-256 certificate fingerprint.
                           Omit to use TOFU (fingerprint is printed and you are
                           prompted to accept it once per session; it is not
                           automatically stored between sessions).
                           Ignored in SSH mode — fingerprint is read from the server.
  --remote-mftp <PATH>     Path to a pre-installed mftp on the remote host.
                           By default mftp pipes itself over SSH stdin on first use
                           and caches it at ~/.cache/mftp-<hash> on the remote.
  --port <PORT>            Port the remote mftp server should bind on (SSH mode only).
                           Defaults to a randomly assigned port. Use this when the
                           transfer port must be in a firewall allow-list.
  -n, --streams <N>        Parallel streams.
                           Direct mode: default auto-negotiated from RTT + CPU cores.
                           By default the sender adapts stream count mid-transfer
                           based on measured throughput and receiver congestion —
                           passing -n N pins the count and disables scaling.
                           SFTP: default 8 (each stream = one SSH connection;
                           raise to 12 if the remote sshd allows it).
      --chunk-size <BYTES> Chunk size in bytes (default: auto from RTT).
      --no-compress        Disable adaptive zstd compression.
  -r, --recursive          Transfer directories recursively. Required when the
                           source is a directory; silently accepted (no-op) when
                           the source is a regular file.
      --preserve           Preserve source file permissions and modification time
                           on the receiver. No effect on Windows receivers.
      --fec <DATA:PARITY>  Enable Reed-Solomon forward error correction.
                           e.g. --fec 8:2 adds 25% bandwidth overhead.
                           NOTE: this does NOT improve throughput — QUIC already
                           retransmits lost packets reliably, so the parity is
                           pure overhead (benchmarked a wash-to-worse up to 30%
                           loss). Leave it off unless you specifically want a
                           receiver to reconstruct an occasional corrupt chunk
                           without an in-band repair round. Auto-disabled on the
                           TCP fallback (reliable delivery).
      --transport <TRANSPORT>
                           Force a specific transport path:
                           quic — QUIC only; fails immediately if UDP is blocked
                                  (no TCP+TLS or SFTP fallback).
                           tcp  — TCP+TLS only; skip the QUIC probe (no SFTP fallback).
                           sftp — parallel SFTP through port 22 (SSH mode only;
                                  significantly slower, ~22 MiB/s cap; skips remote
                                  server launch).
                           Omit for auto: QUIC → TCP+TLS → SFTP (SSH mode only).
      --tcp-below-rtt <MS> In auto mode, switch to TCP+TLS when measured RTT ≤ this
                           value. Ignored when --transport is set [default: 15].
      --download           When the remote platform differs from local, automatically
                           download the correct mftp binary from GitHub releases
                           without prompting. Mutually exclusive with --no-download.
      --no-download        When the remote platform differs from local, skip the
                           download attempt and fall back to SFTP immediately.
                           Mutually exclusive with --download.
      --remote-binary-sha256 <HEX>
                           Expected SHA-256 of the cross-platform binary fetched
                           from GitHub releases. When set, the download must match
                           exactly or the transfer aborts before anything runs on
                           the remote. Without it, the download is still checked
                           against the release's published <asset>.sha256 and the
                           computed hash is printed for manual comparison.
  -v, --verbose            Increase log verbosity (-v / -vv / -vvv).
```

### `mftp receive`

```
mftp receive [OPTIONS] [BIND]
```

`BIND` defaults to `0.0.0.0:7777`. Both QUIC (UDP) and TCP+TLS listen on the same port, so the sender's auto-fallback works with no extra configuration on the receiver side. **The bind port must be reachable from the sender.**

```
Options:
  -o, --output-dir <DIR>   Directory to write received files into (default: .).
```

FEC parameters are negotiated automatically via the `TransferManifest` — no `--fec` flag needed on the receiver.

### `mftp --version`

```
mftp 0.1.119
```

---

## How it works

### Transport

mftp defaults to QUIC (via [quinn](https://github.com/quinn-rs/quinn)) with BBR congestion control. In SSH mode it tries three transports in order, falling back automatically:

```
Sender                                    Receiver
  │                                           │
  ├─── QUIC connect (5 s timeout) ──────────►│  mftp receiver (launched via SSH)
  │    (if UDP blocked or times out)          │  requires: data port open (UDP)
  ├─── TCP+TLS connect ──────────────────────►│  mftp receiver (same process)
  │    (if TCP port also unreachable)         │  requires: data port open (TCP)
  └─── SFTP (N parallel SSH connections) ───►│  sshd sftp-server (port 22 only)
                                             │  requires: SSH port 22 only
```

The QUIC and TCP+TLS paths use TLS 1.3 with a freshly generated self-signed certificate shared between both transports (same fingerprint). The SFTP path bypasses the mftp receiver entirely and writes directly to the remote filesystem via the sshd built-in sftp-server subsystem.

### SSH-assisted launch

When you write `mftp send file.bin user@host:/path`, the sender:

1. SSHes to `user@host` and runs `mftp server --output-dir /path`
   - If mftp is not installed on the remote, the local binary is piped over SSH stdin and cached at `~/.cache/mftp-<hash>` — subsequent transfers with the same binary version skip the copy.
   - The remote OS and architecture are probed first so the correct binary is delivered (Linux/macOS/Windows, x86_64/arm64).
   - Use `--port <N>` to have the remote server bind on a specific port instead of a random one (required when the transfer port must be in a firewall allow-list; without `--port`, the random port will almost certainly be blocked).
2. The remote server binds on the chosen (or random) port and prints one JSON line to stdout:
   ```json
   {"port":54321,"fingerprint":"a3f9..."}
   ```
3. The sender reads the handshake, then attempts a direct connection to `host:54321`
4. If the direct connection fails (firewall blocks the port), the sender falls back to **parallel SFTP** — N independent SSH/SFTP connections each writing a non-overlapping segment of the file directly to the remote. No mftp process is needed on the remote for this leg; it talks to the sshd sftp-server subsystem.

The remote `mftp server` exits as soon as the transfer completes (or is killed when SFTP takes over).

### Firewall configuration

The QUIC and TCP+TLS paths both require a port to be open on the receiver — UDP for QUIC, TCP for TCP+TLS (or both, since the auto-fallback tries both). In SSH mode a random port is picked by default, which will almost certainly be blocked by a firewall; use `--port <N>` to pick a specific port you control.

**Opening a port temporarily (Linux)**

```sh
# iptables (most distributions)
sudo iptables -A INPUT -p udp --dport 7777 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 7777 -j ACCEPT

# firewalld (RHEL/Fedora/Rocky/AlmaLinux)
sudo firewall-cmd --add-port=7777/udp --add-port=7777/tcp

# ufw (Ubuntu/Debian)
sudo ufw allow 7777/udp
sudo ufw allow 7777/tcp
```

These rules are not persistent across reboots. To persist them, add `--permanent` to `firewall-cmd` (then `sudo firewall-cmd --reload`), use `iptables-save`, or add a `ufw` allow rule before enabling ufw persistence.

**Cloud providers**: security group or VPC firewall rules (AWS, GCP, Azure, etc.) must also permit the port — kernel-level rules alone are not enough on cloud instances. The rule must allow inbound traffic from the sender's public IP on the chosen port (UDP and TCP).

**If you cannot open a port**: the automatic SFTP fallback (port 22 only) requires no additional firewall changes. It is slower — typically 22–32 MiB/s versus the full QUIC/TCP throughput — but works through any firewall that allows SSH.

### SFTP fallback

The SFTP fallback uses libssh2 to open N independent SSH connections to port 22, each with its own SFTP channel. The file is divided into N equal segments; each connection writes its segment with positional I/O (`seek` + `write`) in parallel.

Throughput scales linearly with stream count because each connection is fully independent (separate congestion window, separate SSH channel):

| Streams | Throughput (LAN, 500 MiB) |
|---------|--------------------------|
| 4       | ~12 MiB/s |
| 8       | ~22 MiB/s (default) |
| 12      | ~32 MiB/s |

The ceiling per stream (~3 MiB/s) comes from libssh2's synchronous SFTP write acknowledgment — it is a fundamental limitation of the SSH SFTP protocol. Raising `--streams` to 12 is safe on most servers; beyond that, OpenSSH's `MaxStartups` setting (default `10:30:100`) may start rate-limiting concurrent auth attempts.

The SFTP path uses a single encryption layer (SSH), whereas the tunnel approach used SSH + TLS. Authentication uses the SSH agent if running, otherwise the default key files (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`). The remote host key is verified against `~/.ssh/known_hosts`.

### Parallel streams and RTT negotiation

After the QUIC handshake, sender and receiver exchange CPU core counts. The sender then reads the measured RTT from the QUIC connection stats and computes:

| RTT | Default chunk size |
|-----|-------------------|
| < 10 ms (LAN) | 8 MiB |
| 10 – 200 ms (regional/intercontinental) | 4 MiB |
| ≥ 200 ms (satellite) | 2 MiB |

Stream count is `max(⌈RTT_ms / 5⌉, min_cores)`, capped at `2 × min(sender_cores, receiver_cores)`. On a satellite link with 600 ms RTT and an 8-core machine on each end, mftp opens 16 streams of 2 MiB chunks — keeping the pipe full while staying within CPU budget.

Both values can be overridden with `--streams` and `--chunk-size`.

### Data flow

```
File on disk
  └─► ChunkQueue (atomic work-stealing index)
        └─► N parallel tasks, one per QUIC/TCP stream:
              ├─ read chunk from file (pread)
              ├─ detect already-compressed format (magic bytes)
              ├─ BLAKE3 hash of raw chunk bytes
              ├─ zstd compress full chunk; discard if < 5% gain
              ├─ [FEC] accumulate DATA shards into stripe; RS-encode PARITY shards
              └─ send ChunkData / FecChunkData frame (hash + payload)

Control stream (after all data streams finish):
  Sender ──► SenderMessage::Complete { file_hash }
  Receiver ──► ReceiverMessage::Retransmit { chunks }   (v5, if any chunk missing)
       └─ Sender re-sends those chunks as ChunkData here, then Complete again
  Receiver ──► ReceiverMessage::Complete { file_hash }  (after full-file verify)
```

The receiver writes each chunk directly to its final offset with `pwrite`, so no reassembly pass is needed after the transfer.

### Wire protocol

All messages are length-prefixed bincode frames (`[u32 LE length][payload]`):

```
Control stream (1 per connection):
  Sender → NegotiateRequest   { cpu_cores }
  Receiver → NegotiateResponse { cpu_cores }
  Sender → TransferManifest   { transfer_id, file_name, file_size, chunk_size,
                                 total_chunks, num_streams, compression, fec }
  Receiver → ReceiverMessage::Ready { received_bits, total_chunks }  ← resume bitvector
  ...data streams transfer...
  Sender → SenderMessage::Complete  { file_hash }
  Receiver → ReceiverMessage::Retransmit { chunks }  (v5; repair over this stream)
  Receiver → ReceiverMessage::Complete { file_hash }

Data streams (N per connection):
  Without FEC — one ChunkData per chunk:
    Sender → ChunkData { transfer_id, chunk_index, chunk_hash, compressed, payload }

  With FEC — one FecChunkData per shard (data + parity):
    Sender → FecChunkData { transfer_id, chunk_index, chunk_hash, compressed,
                            stripe_index, shard_index_in_stripe, is_parity,
                            shard_lengths, payload }
```

### Reed-Solomon FEC

Enable with `--fec DATA:PARITY` on the sender (e.g. `--fec 8:2`). The receiver accepts both FEC and non-FEC transfers on the same port — the `TransferManifest` advertises whether FEC is active.

**Stripe layout**: chunks are grouped into stripes of `DATA` shards. Each stripe is RS-encoded to produce `PARITY` additional shards. All `DATA + PARITY` shards are sent independently across the parallel streams. The receiver can reconstruct any stripe as long as it receives at least `DATA` out of the `DATA + PARITY` shards — tolerating up to `PARITY` lost shards per stripe without retransmission. A stripe that loses *more* than `PARITY` shards is recovered by in-band incremental repair (see below) rather than aborting the transfer.

**Ordering**: compression happens before FEC encoding, so parity shards are computed over (and are the same size as) compressed data shards. FEC is automatically disabled when the transport falls back to TCP, since TCP guarantees delivery.

**Overhead**: `--fec 8:2` adds 25% to the wire size (`2/8`).

> **FEC does not improve throughput over QUIC — leave it off.** Because mftp's chunks
> ride **reliable QUIC streams**, QUIC already retransmits every lost packet, so chunks
> always arrive intact and there is no permanent gap for parity to fill. The parity shards
> are simply extra bytes competing for the (loss-limited) link, and they are subject to the
> same loss + retransmission. Benchmarks bear this out: across 1%, 5%, 10%, 20% and 30%
> loss (and 300 ms RTT), `--fec` is a wash-to-worse versus plain QUIC, and heavy parity
> (`4:4`) is reliably *slower*. FEC's only real value is letting the receiver reconstruct
> the occasional chunk that fails its BLAKE3 check (or rode a reset stream) without a
> repair round-trip — a niche reliability edge, not a throughput lever. It would pay off
> over an *unreliable/datagram* transport, which mftp does not use.

### Compression

mftp compresses each chunk independently with zstd:

1. **Magic-byte check** — if the first 4 bytes match a known compressed format (gzip, zstd, bzip2, zip, 7-zip, xz, jpeg, png, mp4, mkv/webm…), compression is skipped entirely.
2. **Sample probe** — a 64 KiB leading sample is compressed first; if it doesn't shrink by ≥ 5%, the chunk is sent raw without compressing the rest, so incompressible data not caught by the magic-byte table costs only the sample, not the full chunk.
3. **Full-chunk compression** — if the sample compresses well, the whole chunk is compressed; a final 5% threshold still applies (only the leading portion may have been compressible), and the compressed bytes are discarded if it isn't met.
4. **Adaptive level** — a per-worker EMA of the achieved ratio picks the zstd level: 1 for near-incompressible data, 3 otherwise. It deliberately does **not** escalate to level 6 — measured, that costs ~2× the CPU for only ~7% better ratio, which *reduces* throughput whenever compression (not the network) is the bottleneck.
5. **Per-chunk flag** — `ChunkData.compressed` tells the receiver whether to decompress.

`--no-compress` skips the whole path. On a fast link (LAN / fast datacenter) compression can be the bottleneck — its CPU cost outweighs the bandwidth saved — so prefer `--no-compress` there; mftp's auto TCP switch below `--tcp-below-rtt` doesn't disable compression for you.

### Integrity

- **Per-chunk**: BLAKE3 of the raw (pre-compression) chunk bytes. The sender computes the hash before compressing, embeds it in the frame, and the receiver decompresses then re-computes and compares before writing to disk. A chunk that fails this check is dropped (left unmarked) rather than aborting — see *Incremental repair* below.
- **Full-file**: BLAKE3 of the concatenated per-chunk hashes — `blake3(hash[0] || hash[1] || … || hash[N-1])`. The sender sends this in `SenderMessage::Complete`; the receiver verifies it after all chunks land. A mismatch fails the transfer (and deletes the resume file so the next run starts clean).
- **SFTP fallback**: integrity is provided by SSH's channel MAC (HMAC-SHA2-256). Per-chunk and full-file hashing are not available on this path since there is no mftp receiver process.

### Incremental repair

When chunks are missing at the completion checkpoint — a dropped corrupt chunk, or an FEC stripe that received too few shards to reconstruct — a protocol-v5 receiver requests exactly those chunks back via `ReceiverMessage::Retransmit`. The sender re-reads them and re-sends them as plain `ChunkData` on the **existing control stream**, so the connection (and its warmed congestion window) stays alive instead of being torn down. This is single-file only and bounded (`MAX_REPAIR_ROUNDS`); if repair is unavailable or exhausted the transfer fails and a resumed rerun re-fetches only the missing chunks.

### Resume

Each transfer has a deterministic 16-byte ID derived from the file name, file size, and negotiated chunk size (`BLAKE3(name || size || chunk_size)[..16]`). This means re-sending the same file automatically resumes an interrupted transfer — no flags needed. If the negotiation produces different parameters (e.g. different RTT), the ID changes and a fresh transfer starts. The ID is embedded in every `ChunkData` frame.

On the receiver side, a bit-vector tracking which chunks have been received is flushed to `<output_dir>/<transfer_id_hex>.mftp-resume` in batches (every 64 chunks) to limit fsync overhead. If the transfer is interrupted, at most 64 chunks may need re-downloading on resume.

If the transfer is interrupted:

1. Restart `mftp receive` (or re-run the same `mftp send` command in SSH mode)
2. The receiver finds the resume file, reads which chunks it already has
3. In the `ReceiverMessage::Ready` response it sends the received-chunk bitvector
4. The sender skips already-received chunks and only retransmits what's missing

The resume file is deleted on successful completion. Resume is not available on the SFTP fallback path.

### Security

mftp uses self-signed TLS certificates with a TOFU (Trust On First Use) model, similar to SSH:

- The receiver generates a fresh key pair on every start
- It prints the SHA-256 fingerprint of its certificate
- On first connect to a new server the sender prompts for confirmation (requires a TTY; non-interactive invocations without `--trust` are rejected)
- Pass `--trust <fingerprint>` to pin a fingerprint for scripted or non-interactive use; the fingerprint is not stored between sessions automatically

For SSH-assisted transfers the fingerprint is obtained automatically over the existing SSH channel — no manual verification step required.

The SFTP fallback path relies on SSH host key verification against `~/.ssh/known_hosts` (the same file used by the `ssh` command). Run `ssh <host>` once if the host is not yet in your known_hosts.

Socket buffers are set to 32 MiB (`SO_SNDBUF` / `SO_RCVBUF`) on both ends. QUIC flow control windows start at 64 MiB per stream and 512 MiB at the connection level; the connection-level window is scaled up dynamically after RTT is measured to match the bandwidth-delay product on high-latency links (e.g. ~1 GiB at 600 ms RTT on a 10 Gbps link).

---

## Performance

mftp vs `scp` vs `zap` (parallel-SSH copy), 1 GiB incompressible file, netem-shaped
latency/loss on a gigabit-class link. Median of 3 runs; page cache dropped on both
ends and the received file fsync'd **into** the timing ("durability-fair", so no tool
is flattered by the receiver's write cache — see [`tests/compare.sh`](tests/compare.sh)).
MiB/s of the original file size:

| Link | scp | zap | mftp (auto) |
|------|----:|----:|------------:|
| 50 ms RTT | 34 | 62 | **111** |
| 150 ms RTT | 12 | 40 | **106** |
| 150 ms + 1% loss | 9 | 20 | **39** |

scp's single TCP stream collapses as latency rises; zap's parallel SSH streams hold a
middle band; mftp's QUIC + adaptive streams stays near-flat (~110 MiB/s from 50→150 ms)
— **~2.6× zap and ~9× scp at 150 ms**, and the gap widens with RTT.

On a sub-15 ms LAN mftp uses the auto TCP+TLS path, and the receiver's *disk write*
becomes the bottleneck, not the protocol — durability-fair, mftp sustains ~150+ MiB/s
because it overlaps writeback with the transfer, while a cache-warm measurement can make
scp look momentarily faster simply by buffering into RAM (it then pays a long fsync). For
compressible data mftp pulls further ahead (adaptive zstd ships fewer bytes); `--fec` does
**not** help at any loss rate — see [Reed-Solomon FEC](#reed-solomon-fec).

> Absolute numbers are testbed- and disk-dependent; the ratios and the RTT-flatness are
> the point. Reproduce with `tests/compare.sh` (set `REMOTE_USER`/`REMOTE_HOST`).

---

## Known limitations

- **Firewall**: the QUIC and TCP+TLS paths both require an open port on the receiver. In SSH mode a random port is used by default — likely to be blocked. Use `--port <N>` with a known-open port, or rely on the automatic SFTP fallback (port 22 only, but capped at ~22–32 MiB/s). See [Firewall configuration](#firewall-configuration) above.
- **SFTP fallback is significantly slower**: the SFTP path is capped at ~3 MiB/s per stream (a fundamental SSH SFTP protocol limitation — synchronous write acknowledgments). At the default of 8 streams that is ~22 MiB/s; 12 streams gives ~32 MiB/s. This is several times slower than the QUIC or TCP+TLS paths, which saturate the link. If transfers are consistently falling back to SFTP, open a port and use `--port <N>`.
- **TOFU fingerprint persistence**: `--trust` fingerprints are not stored between sessions. You must pass `--trust` on every non-interactive invocation, or accept the prompt each time.
- **Directory transfer**: `-r` transfers the directory tree recursively. `--preserve` copies mode bits and mtime; without it, files land with default umask permissions and current mtime.

---

## Performance tips

- **Satellite / high-latency links**: mftp is designed for these. Let RTT negotiation pick the parameters; don't override unless you have a reason.
- **Lossy links**: no action needed — QUIC's own retransmission handles packet loss. Do **not** add `--fec`: benchmarking up to 30% loss shows it doesn't improve throughput (QUIC already recovers loss, so parity is pure overhead) and heavy parity is slower. See [Reed-Solomon FEC](#reed-solomon-fec).
- **LAN / datacenter transfers**: mftp auto-switches to TCP+TLS when it measures RTT ≤ 15 ms. No flags needed — just run the same command.
- **Pre-compressed data** (videos, archives, already-zstd files): mftp auto-detects these and skips compression. No `--no-compress` needed.
- **Open port required**: in SSH mode, use `--port <N>` with a firewall-allowed port to avoid the automatic fallback to SFTP. The SFTP path is reliable but slower.
- **SFTP fallback throughput**: if the direct transfer ports are always blocked, raise `--streams` from the default of 8 to 12 for ~32 MiB/s. Check that the remote sshd's `MaxStartups` is set to at least `12:30:100`.
- **OS socket buffer limit**: mftp requests 64 MiB socket buffers, but the Linux kernel clamps to `net.core.rmem_max`/`wmem_max` (it logs a warning if it can't grant the full amount). Raise both on each host for maximum throughput on high-BDP links:

  ```sh
  sudo sysctl -w net.core.rmem_max=67108864 net.core.wmem_max=67108864
  ```

  On very-high-BDP paths (10 Gbps × high RTT) raise the sysctls further and set `MFTP_SOCKET_BUFFER=<bytes>` to request a larger buffer.

- **Faster congestion ramp**: QUIC starts at a 1 MiB BBR congestion window (raised from quinn's ~234 KiB default) so high-RTT transfers reach line rate a few RTTs sooner. Tune with `MFTP_INITIAL_CWND=<bytes>` — lower it on thin/constrained links where a large first-flight burst would cause startup loss.

- **TCP BBR on the TCP+TLS path**: mftp requests BBR congestion control for TCP sockets on Linux (mirroring the QUIC path). If you see a warning about `TCP_CONGESTION=bbr`, the `tcp_bbr` kernel module is not loaded on that host. Load it manually or persist it across reboots:

  ```sh
  sudo modprobe tcp_bbr
  # persist across reboots:
  echo tcp_bbr | sudo tee /etc/modules-load.d/tcp_bbr.conf
  ```

  Failure to load the module is non-fatal — the transfer continues with the kernel default (usually CUBIC). BBR matters most on the TCP+TLS path (auto-switched on LAN, or forced with `--transport tcp`).

- **Diagnosing slow transfers**: in a wide terminal (≥ 140 columns) the progress bar shows a live diagnostic: `streams=N rtt=Xms loss=N stall=Nms`. Fields only appear when non-zero. A persistent high `stall=` value means the receiver disk is the bottleneck; a non-zero `loss=` points to network packet loss.

---

## Building

```sh
cargo build --release
```

Requires Rust 1.75+ (for `div_ceil` stabilization) and libssh2 (for the SFTP fallback). On most Linux distributions libssh2 is already installed; on others install `libssh2-devel` (RPM) or `libssh2-dev` (Debian/Ubuntu). On Windows, OpenSSL is vendored automatically — no extra setup needed.

> **Platform support**: Linux (x86_64, arm64), macOS (x86_64, aarch64), and Windows (x86_64) are all supported. CI builds and runs the full test suite on Linux, macOS, and Windows on every change.

```sh
# Check only (fast)
cargo check

# Tests
cargo test

# Lint
cargo clippy -- -D warnings
```

### Building on macOS

mftp is fully supported on macOS (x86_64 and aarch64). If you encounter linker errors related to missing zlib symbols (`_deflate`, `_inflate`, etc.) during `cargo build`, this is caused by `libz-sys` picking up the wrong architecture library from MacPorts or Homebrew. The repository includes a pre-configured `.cargo/config.toml` that forces static zlib linking and sets the correct deployment target; no additional steps are needed beyond running `cargo build --release`.

If you need to cross-compile (e.g., building for x86_64 on Apple silicon), the same configuration applies automatically:

```sh
# Build for x86_64-apple-darwin (on Apple silicon host)
cargo build --release --target x86_64-apple-darwin
# Output: target/x86_64-apple-darwin/release/mftp

# Build for aarch64-apple-darwin (native on Apple silicon)
cargo build --release --target aarch64-apple-darwin
```

The `.cargo/config.toml` settings that enable this:

```toml
[env]
LIBZ_SYS_STATIC = "1"
MACOSX_DEPLOYMENT_TARGET = "12.0"
```

`LIBZ_SYS_STATIC=1` forces zlib to be built from source instead of discovering a system library of the wrong architecture, and `MACOSX_DEPLOYMENT_TARGET=12.0` ensures C dependencies target macOS Monterey or later for binary compatibility.

---

## Roadmap

- **Fingerprint persistence** — store `--trust` fingerprints across sessions (keyed by host)

For shipped features and per-release history, see [CHANGELOG.md](CHANGELOG.md) and the [releases page](https://github.com/OmarHermannsson/mftp/releases).

---

## License

MIT
