Skip to content

Release 0.4.0 - #78

Merged
op-q merged 45 commits into
mainfrom
release/0.4.0
Sep 16, 2026
Merged

op-q merged 45 commits into
mainfrom
release/0.4.0

Conversation

@op-q

@op-q op-q commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Everything for 0.4.0 in one pull request: the twelve stacked PRs (#65 to #76), #77, and the two dependency updates (#63, #62). Each one was merged in order with a merge commit, so its history stays readable here and in its own PR.

The release notes are docs/releases/v0.4.0.md. Please read them as part of this review, because they become the GitHub release text.

On top of the merges

  • Version 0.4.0 in Cargo.toml and Cargo.lock.
  • Release notes are now a file. release.yml refuses a tag with no docs/releases/<tag>.md and publishes the file as the release text. (0.2.0 and 0.3.0 went out with empty pages.) AGENTS.md now has the rules for writing the notes.
  • README platform table: Intel macOS and aarch64 Linux are no longer listed as tested in CI. The release workflow only builds and starts them.

Merging

  • Use Create a merge commit. Squash or rebase would flatten the stack, and GitHub could no longer mark the other PRs merged.
  • The "Analyze JavaScript and TypeScript" check in branch protection has to become "Analyze Rust" (Remove the browser client, keep the relay (removal phases 1–4) #69 renames the job), or this PR waits forever. Remove "Web" as well.
  • After merging, tag v0.4.0 on the merge commit to start the release.

Validation

  • Linux, locally: secret scan, cargo fmt --check, Clippy with -D warnings and cargo test --workspace --all-targets all pass. 253 tests passed, 0 failed, and 2 ignored (the cross-OS tests, which CI runs).
  • CI on this PR: Rust on Linux, macOS and Windows; installers on Linux and Windows; nine cross-OS archive pairings.

Not proven

  • Two real computers on different operating systems have not transferred over a real network.
  • The Windows checklist has not been run by hand.
  • Hole punching through NAT is unproven.
  • A cancel over the direct path is not tested.
  • The relay's Docker image has not been built since its fix.

🤖 Generated with Claude Code

dependabot Bot and others added 30 commits September 10, 2026 14:24
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](pytest-dev/pytest@8.0.0...9.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [iroh](https://github.com/n0-computer/iroh) from 1.0.3 to 1.1.0.
- [Release notes](https://github.com/n0-computer/iroh/releases)
- [Changelog](https://github.com/n0-computer/iroh/blob/main/CHANGELOG.md)
- [Commits](n0-computer/iroh@v1.0.3...v1.1.0)

---
updated-dependencies:
- dependency-name: iroh
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
The user set priorities on 2026-09-14: the receiver sees what is coming and
accepts before anything is written, either side can cancel, the sender sees the
receiver's state throughout, NAT traversal is proven rather than assumed, and
drop works on Windows, macOS and Linux with transfers between them.

Two new plans. Receiver consent and status supersedes the 2026-08-19
confirmation plan, which was written against cleartext metadata. Cross-platform
records that Windows has no build and macOS has never had a test run.

The browser removal and browser-on-iroh plans from 2026-09-11 are committed,
with the removal's four open questions answered as the plan leaned.

A re-run of netlab found that hole punching has never been exercised there:
QUIC address discovery fails TLS against the lab helper's self-signed
certificate, so no peer learns its public address. The helper's comment and the
rendezvous plan both claimed iroh skips that check; it does not. Recorded in
the netlab plan, with the fix proposed as self-hosted rendezvous phase 3 and
left for a decision because it changes what the CLI trusts.

Also corrected: meta_ok confirmation was to land before the direct path
shipped, and the direct path shipped in v0.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
`install.sh` has nothing to do with the browser client. It lived in
`web/public/` only because entry 8's split deployment served it as a static
file. The release workflow publishes it from that path under
`fail_on_unmatched_files`, so deleting `web/` first would fail the next tag in
`publish`, after the whole build matrix had already succeeded. Moving it
first keeps the release path intact through every commit of the removal.

The relay's `/install.sh` route goes too. It served the file so a curl against
the hosted relay worked, and entry 16 removed that host. The README already
points at the release asset.

The script is byte-identical to the v0.3.0 release asset, and
`DROP_VERSION=v0.3.0` installs a binary that reports 0.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
No `cfg(not(unix))` branch in the CLI has ever been compiled: CI runs on
Ubuntu alone, and the release workflow only ever executes `drop --version` on
macOS and has no Windows target. Cross-platform phase 0 is to find out what
breaks before fixing any of it, so this adds Clippy and the full test suite on
`macos-14` and `windows-2025`.

A separate job rather than a matrix on `rust`: formatting cannot vary by
platform, and branch protection requires a check named exactly "Rust", which a
matrix would rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
The receiver printed the sender's filename verbatim in its "Receiving" line,
before the receiver had agreed to anything. A name carrying an escape sequence
could clear the line, move the cursor, retitle the window or plant a hyperlink,
and a right-to-left override could make `exe.pdf` read as `fdp.exe`. Error
messages from a peer, or from the relay, which is equally untrusted, reached
the terminal the same way.

`display.rs` replaces control characters and bidirectional and invisible
formatting characters with U+FFFD rather than deleting them, so a doctored name
looks doctored. It collapses whitespace padding and shortens long names in the
middle, keeping the extension. Honest names in any script, emoji included, pass
through unchanged. Applied to every place where someone else's text is printed.

Pinned end to end through the real binary over a real relay. With the fix
reverted, the test fails on the escape sequence reaching the receiver.

Receiver consent plan, phase 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
The first Windows run failed Clippy on two items only the symlink tests use:
an import in the archive tests and the hostile-archive builder. Everything
else, including every cfg(not(unix)) branch in the CLI, compiled.

The test step now runs even when Clippy fails, so one run reports both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Entry 16 removed the browser client's audience: with no hosted relay, only
someone self-hosting the full-stack image could reach it. It had also never
been exercised by a browser, and every wire change planned for 0.4.0 would have
had to be carried through it untested.

Gone: `web/`, `crypto-wasm/`, the relay's `/` and `/assets` routes, CORS and
`DROP_ALLOWED_ORIGINS`, `Dockerfile.fullstack`, the `web` CI job, npm in
dependabot, and `.cargo/config.toml`'s wasm rustflag. `GET /` now answers 404
with one line saying what the host is. CodeQL analyses Rust instead of the
TypeScript that no longer exists.

The relay stays. `--transport relay` is what works on a network that lets no
UDP out, and netlab covers that. The rules about what a browser transfer may be
called stay too, marked dormant, since they bind any future browser client.

Found on the way: `Dockerfile` has not built since the envelope moved into its
own crate. It copied the CLI's manifest but not `crypto/`, so cargo could not
load the workspace. Reproduced by copying exactly the Dockerfile's files into a
scratch directory, and fixed there with `--release --locked`.

Recorded as decisions entry 17; entry 11 is marked superseded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
The relay refuses a frame that is too large from its header and closes the
socket, which can happen while the test is still writing the frame's body.
Linux's socket buffer usually absorbs that write; macOS's usually does not,
and the second CI run on macOS failed with a broken pipe. The relay behaved
correctly both times.

The write may now fail by the relay hanging up. What is asserted is still what
the receiver saw: an error, and no part of the chunk.

Also records what the first macOS and Windows runs found in the cross-platform
plan. Windows passed the whole suite once two Unix-only test helpers were gated,
and every non-Unix branch in the CLI compiled for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Before this, a folder with a symlink in it stopped extracting on Windows at the
first link. Names ordinary on Linux and macOS were worse. `notes.txt:hidden`
would have become an NTFS stream on `notes.txt`, `CON` a device, `report.` a
file quietly named `report`, and `a<b` an error that ended the extraction.

Names are rewritten, not refused, because most of them come from honest
senders: `10:30 standup.md` is a normal name on a Mac. Each character Windows
would interpret becomes `_`, trailing dots and spaces become `_`, and a device
name gets `_` after its stem. A rewrite never introduces a separator, so it
cannot move an entry to another directory. The existence and link checks run on
the rewritten path, and every rename is reported.

Archive paths are now split on `/` by hand rather than handed to `Path`, which
reads `C:x` as a drive on Windows and as a name elsewhere, so every platform
judges the same components.

A name the filesystem refuses, or a link the system cannot create, now skips
that entry with a warning instead of ending the extraction. A full disk or a
permission problem still ends it. Pinned without Windows by a 300-byte name;
with the old behaviour restored, that test fails.

Cross-platform plan, phase 1. The Windows-only tests run in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
It does not. The relay repeats the few envelope constants it enforces, and
cli/tests/protocol.rs fails if a copy drifts. architecture.md said otherwise,
and the removal carried that claim into the Dockerfile, two manifest comments,
lib.rs and decisions entry 17. The Dockerfile change itself stands: cargo needs
crypto/ because it is a workspace member and the CLI's path dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Entry 13 has the sender count guesses and ask a human before allowing another,
so that being probed is visible. It rested on `meta_ok`, a bare claim made by
the party being limited. A guesser who could not open the metadata could say
`meta_ok` anyway, and the counter never climbed. Nothing readable ever leaked,
but the noticing did not hold.

`meta_ok` now carries a key confirmation, a fourth HKDF output only a peer
holding the same keys can produce. The sender compares it in constant time,
inside `crypto/` so a later `==` cannot creep in at a call site. A missing,
malformed or wrong one is a failed attempt like any other.

Both carriers run the checkpoint. The relay forwards `meta_ok` rather than
refusing it, and over the relay the sender reads past the relay's own `sending`
and `progress` narration, which would otherwise count as a failed guess. A
failure there ends the transfer, since the relay has already burned the
session.

`ENVELOPE_VERSION` and `DROP_ALPN` both become 2, and a test ties them
together. A 0.3.0 peer or relay is refused, and the relay's error now names
both versions. The direct path already shipped in v0.2.0, so this is a wire
break; the consent work lands under the same unreleased version.

Test peers now run a real handshake through `ScriptedTransport::responding`,
since no fixed script can prove it holds keys derived from the sender's fresh
half. With the comparison forced true, the claims test fails on a bare meta_ok.

Recorded as decisions entry 18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
A receiver used to learn what it was getting as it arrived, and the file was
created before any question could be asked. Now, once the key confirmation
passes, the receiver is shown the name, a type label from the real extension
(with a warning for programs), the size, a folder's file count and unpacked
size, and exactly where it would be saved. Nothing is created until they
accept.

The destination is planned by looking rather than creating, so declining
leaves the directory byte-identical, and the name shown is the name written.
A question unanswered for two minutes is declined. Without a terminal,
`drop recv` refuses to start unless given `--yes`, and refuses before
contacting anything so the sender's code is not spent.

The relay learns `accept`, `decline`, `cancel` from either side and
`finishing`. It carries no chunk before `accept`, forwards reasons only from a
fixed set, and counts declines and cancels apart from failures. The sender
waits for the answer and says what the receiver is doing.

While the question is open the receiver keeps reading, because a relay drops
a socket that stops answering pings. That read is abandoned when the person
answers, so receiving must be cancel-safe, and the QUIC framing was not:
`read_exact` into a local buffer loses a partial frame when dropped. It now
keeps the partial frame in the transport.

Negative controls: without the relay's gate the early-chunk test fails; with a
file created on the decline path the decline test fails; the old framed reader
fails the cancel-safety test.

Consent plan phases 2 and 3; decisions entry 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
The first Windows run of the naming tests showed that storing `C:x` as sent
there is refused: PathBuf::push treats it as drive-relative and replaces the
destination, and the final inside-the-destination check catches that. Names
are never stored as sent on Windows, so the refusal is the correct outcome and
the test now asserts it, pinning the safety check that found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Ctrl-C used to exit without a word. The peer found out from a dropped
connection, and the relay counted it as a failure. Now the first Ctrl-C (or
SIGTERM) cancels: whatever the transfer waits on next sends the peer `cancel`
and stops, and the other side says who stopped it. A second Ctrl-C, or two
seconds without stopping, quits at once as before.

The mechanism is a wrapper around the transport, since every wait in both
directions already goes through one. Sends are refused before they start,
never interrupted, so no frame is left half-written.

Two bugs found on the way, both covered by tests:

- A receiver left a partial file behind on any early exit other than an
  integrity failure, including a dropped connection. A guard now deletes it
  unless every byte arrived and was verified.
- A sender that was mid-write when the receiver cancelled reported "Broken
  pipe": the relay forwards `cancel` and closes, and the write fails first. A
  failed write now reads what already arrived and reports the peer's reason.

Exit statuses: 3 declined or not answered, 4 cancelled by the other side, 130
cancelled here. With --status each side prints `drop-status: state=` lines,
which netlab now asserts in the relayed topology.

Consent plan phase 4; decisions entry 20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
On Windows, where a PathBuf is larger, the two fields receiver consent added
to Payload pushed Attempt::FailedTheCode past Clippy's large-variant limit,
failing CI there only. The payload is boxed; nothing reads it except the
retry that hands it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
…ow closes

Three things only a Windows sender got wrong:

- Link targets were recorded as `read_link` gave them, `..\shared\config`,
  which a Linux or macOS receiver takes as one name with backslashes in it.
  Relative targets are now written with `/`, and absolute ones, which would
  dangle on any other machine, are left out with a reason.
- Closing the console window, logging off and shutting down are not Ctrl-C on
  Windows, and nothing handled them, so a compressed send's spool file (a copy
  of the user's data) stayed in %TEMP%. They now start the same cancel-and-clean
  path as Ctrl-C, as does Ctrl-Break.
- The progress line assumed escape sequences work. The older console that
  cmd.exe still opens prints them literally until VT processing is turned on.
  It is turned on now, and where that fails the line is redrawn with padding
  instead.

Also pinned on every platform: a spool file can be deleted while the chunk
reader has it open, which on Windows depends on how the file was opened.

The Windows-only code is compiled first by the Windows CI runner.

Cross-platform plan, phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
The release workflow builds x86_64-pc-windows-msvc, and aarch64 on an Arm
runner, as zips holding drop.exe. The C runtime is linked statically, so it
starts on a machine without the Visual C++ redistributable. The Arm build is
marked continue-on-error, since a toolchain problem there must not hold back a
release. Checksums cover the zips, and install.ps1 is published beside
install.sh.

install.ps1 is the Windows counterpart of install.sh, with the same three
variables. It verifies the checksum, installs to %LOCALAPPDATA%\Programs\drop,
and adds that directory to the user's own PATH, without elevation. It runs in
its own script block and fails with `throw`, because through `irm | iex` a bare
`exit` would close the user's PowerShell window. install.sh run from Git Bash
now points at it instead of saying the OS is unsupported.

A new Installer CI job runs both installers for real on every pull request.
It serves locally built packages laid out like a release, checks the installed
binary runs, and checks that a tampered checksum is refused and installs
nothing. install.ps1 runs under both pwsh 7 and Windows PowerShell 5. The Linux
half was run locally.

Cross-platform plan, phase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Only a normal completion drained the sender's socket before dropping it.
When a receiver declined or cancelled mid-stream, the relay stopped reading the
sender's chunks, so its close became a TCP reset. Linux keeps the preceding
`cancel` frame readable after a reset; Windows discards unread data, so the
Windows CI run of the receiver-cancel test saw "connection aborted" instead
of the receiver's reason.

Both socket tasks now drain until the peer answers the Close, whenever their
loop ended with the socket still open, bounded by the existing deadlines. The
download socket had no drain at all, which the teardown plan had recorded as a
latent problem of the same shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
op-q and others added 15 commits September 15, 2026 12:34
A cross-OS transfer can differ by platform in only two places: how the sender
reads a tree, and how the receiver writes one. The wire is the same
everywhere. Two CI runners cannot swap a transfer code, so the transfer is
split in two.

`produce`, on each OS, builds a tree using every kind of name and file that OS
can hold and archives it exactly as `drop send` does. `consume`, on each OS,
receives every OS's archive, tar and gzipped, through the real receive path
over an in-process relay. It checks contents, directories, links (kept on Unix,
skipped on Windows), executable bits, and Windows name rewriting. That is nine
pairings.

The workflow runs on pull requests that touch the tree code, nightly, and on
demand. With one byte of an archive flipped, consume fails naming the file.

Cross-platform plan, phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
A direct-path sender ended its whole transfer if anything reached its socket
first with a QUIC handshake that failed. The network lab traced the
intermittent `authentication failed` to exactly that: a late packet from a
relay's address-discovery port was taken for an incoming connection, failed,
and ended the send before the real receiver dialled. Plain LAN passed 1 run
in 5 on main. On a public address, anyone who can send one UDP packet could do
the same on purpose.

A failed handshake is now dropped and the wait goes on. It is not the
receiver, which completes the handshake, and not a guess, which needs a
completed handshake first, so the one-guess rule is untouched.

After the fix, plain LAN passed 8 runs in 8. A unit test sends a junk QUIC
Initial to a waiting sender and then connects a real receiver; with the old
behaviour it fails 3 times in 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
The Windows CI run showed the sender still reporting "connection aborted"
when the receiver cancelled, after the relay learned to drain. The sender only
read when its window was full, so it never answered the relay's close. The
drain expired, the relay reset the connection, and Windows discarded the unread
`cancel` along with everything else. Reading after the failed write, which
works on Linux, cannot get it back there.

The sender now reads whatever has already arrived before each chunk, without
waiting, which is safe because a receive must be cancel-safe. Acknowledgements
move the window as before, and a `cancel` ends the transfer with the
receiver's reason while it is still readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
One Windows run of the installer job started install.ps1 before the local
Python server was listening, and the download failed. Both halves of the
job now poll the server until it answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7Fy45hUvna94cd79WKG8S
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.2.0 and 0.3.0 went out with empty release pages: the notes lived in a
commit message and nothing carried them to GitHub. A release now reads
docs/releases/vX.Y.Z.md as its text, and the verify job refuses a tag
without one before anything is built, because the release action only warns
about a missing file and publishes an empty page anyway.

AGENTS.md gets the rules for writing them: three headings at most (Before
you upgrade, Added, Fixed), one short bullet per change a user would notice,
and nothing about refactors, CI, dependencies or PR numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A minor bump, and a breaking one: protocol version 2 means 0.4.0 does not
transfer with 0.3.0 or older, on either path or through an older relay
(decisions.md entry 18). `drop recv` without a terminal now needs `--yes`.

The notes are docs/releases/v0.4.0.md. Also here:

- The README platform table no longer says Intel macOS and aarch64 Linux are
  tested in CI. CI tests on macos-14 and x86_64 runners; those two targets
  are only built and started by the release workflow.
- Consent phase 6 ticked: its documents landed with phases 1-4.
- The checklist item about a binary reporting the wrong version is gone;
  release.yml's verify job has refused a mismatched tag since 0.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@op-q
op-q merged commit 17bea17 into main Sep 16, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant