Skip to content
All projects
Rust·Move files across an air gap with a camera

Porter

Air-gapped file transfer over dynamic QR codes. A terminal sender animates a file as QR frames; a Flutter app films the screen and rebuilds the bytes. No network, no cloud — the QR codes are the wire.

160
tests across Rust and Dart, including cross-language parity
2
independent implementations of one bit-exact wire format
82x
faster fountain decode after fixing a quadratic endgame

Stack

RustratatuiaxumDartFlutterLT fountain codesGitHub Actions

The problem it solves

Some machines are not on a network, and are not going to be. The usual answers are a USB stick you are not allowed to plug in, or a support ticket. Porter uses the one channel that is always open: the screen is already showing you data, and you already have a camera.

A terminal sender renders the file as a slideshow of QR frames. A Flutter app points a camera at that screen, decodes frames as they go past, and reassembles the original bytes — verified against a SHA-256 the sender transmits in a final frame.

Why it is on this page

Because the interesting problem is not "draw a QR code". It is that a camera filming a screen drops frames, unpredictably and in bursts. A naive sequential transfer means the receiver must eventually catch frame 48,213 specifically, and will sit there through several full loops waiting for it.

Porter's answer is a Luby Transform fountain code. Every frame is the XOR of a pseudo-random subset of source blocks, so no individual frame is special — collect enough of any of them and the file decodes. The subset is derived from the frame's sequence number by a PRNG both sides run independently, so the mapping is never transmitted.

That constraint is the spine of the project: an xorshift32 PRNG and a robust-soliton degree table must produce bit-identical results in Rust and Dart, or transfers fail silently. It is enforced with shared fixtures and documented as a normative wire-format spec.

What real usage taught it

Most of the hard bugs only appeared at scale, on a genuine 115 MB transfer with 70,965 source blocks — none were visible at test sizes:

  • A quadratic endgame. Every peeled block rescanned the entire pending pool, so decoding collapsed from ~190,000 symbols/s to 64/s right at the finish, freezing the UI for 78 seconds at a stretch. A reverse index from block to dependent symbols made it linear: 99.4 s → 1.2 s.
  • Boxed integer lists. Dart's List<int> costs roughly 8 bytes per byte. Switching the symbol buffers to Uint8List cut decoder memory from 158 MB to 56 MB, measured.
  • A 32-bit overflow in the degree table. i * (i - 1) wraps past i ≈ 65536, silently corrupting the probability distribution in release builds for any large file.
  • Content-hashed transfer IDs are not session IDs. The ID derives from the file's SHA-256, so re-sending the same file at a different terminal size reuses it with a different block size — and the two streams are mutually undecodable. Transfer identity had to become (id, K, blockSize).

Down to one runtime

The sender began as TypeScript, which meant the sending machine needed Node installed — precisely the machine least likely to have it, on a tool whose whole premise is that it is cut off from everything.

It moved to Rust for a single static binary, and ratatui bought the sender controls the hand-rolled ANSI renderer could not: scrubbing, jump-to-chunk, and a gap-fill mode that loops only the frames a receiver reports missing. The last subcommand to hold out was join, pure local file concatenation with a checksum — no wire-format surface, so nothing to keep in sync. Porting it let the Node package and its entire lint/format toolchain be deleted outright.

The repo is now two languages instead of three, with no JavaScript anywhere in it. The wire format shrank from three implementations to two, which is one fewer place for a bit to drift.

Design constraints

  • The receiver never blocks on the UI thread. Decoding, peeling and disk I/O live in a worker isolate; only small progress snapshots cross back.
  • Progress must be honest. Fountain decoding recovers almost nothing until a late avalanche, so a bar tracking recovered blocks reads as frozen. It tracks collected symbols against the ~2x K actually needed — measured, not assumed.
  • Interrupted transfers resume. Blocks are written to disk as they are recovered and the seen-symbol set is persisted to an append-only sidecar, so a killed app does not restart from zero.
  • Memory stays bounded. Above a size threshold the un-peeled symbol pool spills to disk, which keeps a gigabyte-scale transfer from becoming an out-of-memory crash.

Honest limits

QR is roughly a 0.3 MB/s channel. A 1 GB file is about 9.5 hours of continuous scanning, and no amount of engineering changes that — so the sender now estimates the duration up front and asks for confirmation rather than letting you discover it 90 minutes in. Porter is built for the tens-of-megabytes case, where it is genuinely the shortest path between two machines that cannot talk.

Let’s build something that lasts

Hiring a senior engineer or architect for a remote team? I work across EU and US time zones - tell me what you're building.

Newsletter

I write about software architecture, PHP, Vue, TypeScript, and developer experience. No spam, unsubscribe anytime.

Copyright © 2026. All rights reserved.