Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ cargo build --release --features gui --no-default-features
./target/release/p2p-transfer receive --output ./downloads --port 14567 --auto-accept
./target/release/p2p-transfer receive --output ./downloads --rendezvous host:14570 --code ABC123
./target/release/p2p-transfer discover
./target/release/p2p-transfer resume <transfer-id> --path <orig-path> --peer <ip:port> --peer-fingerprint <hex>
./target/release/p2p-transfer resume <transfer-id> --path <orig-path> --rendezvous host:14570 --code ABC123
# Resume is automatic: re-run the same `send` to continue an interrupted transfer (or --no-resume to start over)
./target/release/p2p-transfer nat-test
./target/release/p2p-transfer nat-test --rendezvous host:14570 # self-loop punch test
./target/release/p2p-transfer history
Expand Down Expand Up @@ -184,7 +183,7 @@ python3 test_transfer.py --size 50 --compressible # ratio > 100×
- **Don't nest Tokio runtimes.** Anything that calls `Iced::run` must be reached *outside* `block_on`; that's why `run_cli_sync` returns early for the GUI cases.
- **The QUIC bidi control stream only materialises on the responder once the initiator writes to it.** Real handshake code does this immediately; tests that don't exchange messages must either send a marker first or use the same `oneshot` "hold the connection" pattern the existing tests use.
- **Adaptive compression accounting**: track uncompressed size from `chunk_data.len()` *before* compression, not from the compressed payload, otherwise stats and SHA-256 boundaries break.
- **Resume state files** are written as `transfer_<uuid>.json` in the working directory at the time of the transfer. Resume requires the original `--path`, `--peer`, and `--peer-fingerprint` because the file doesn't store any of them.
- **Resume is automatic — there is no `resume` subcommand.** Re-running the same `send` finds a prior incomplete `transfer_<uuid>.json` by `(peer fingerprint, file list)` and continues it; `--no-resume` forces a fresh transfer. The state file records the negotiated `peer_fingerprint`, and lookup matches the source's `(path, size, mtime)` strictly. State lives in a per-user data dir by default (`p2p-cli`'s `default_state_dir`); `--state-dir` overrides.
- **Receiver event loop**: the receiver stays alive after a transfer finishes and accepts further transfers on the same connection until the peer disconnects — don't add logic that exits after the first transfer.
- **Chunk indices are `u64` end-to-end**. `ChunkReader::total_chunks`, `read_chunk`, `fold_chunk`, `ChunkWriter::write_chunk` and the wire format all use `u64`. Do not narrow back to `u32` anywhere on the chunk path — that's what previously truncated large files at `2^32` chunks.
- **Sanitize before joining paths.** Anything written under the output directory goes through `transfer_folder::sanitize_relative_path` first — adding a new write site means routing it through the same sanitizer.
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed — 2026-05-30 — Resume folded into `send`
- Resume is no longer a separate subcommand — the `resume` command is
**removed**. Re-running the same `send` auto-detects and continues a
prior incomplete transfer to the same peer.
- Detection is keyed by `(peer fingerprint, file list)`: the state file
now records the negotiated peer fingerprint, and `send` enumerates the
source and matches it strictly on every file's `(path, size, mtime)`.
Any drift starts a fresh transfer; `--no-resume` forces one.
- Resume state moved from the current working directory to a per-user
data dir by default (`%APPDATA%\p2p-transfer\state`,
`$XDG_DATA_HOME/p2p-transfer/state`, or
`~/Library/Application Support/p2p-transfer/state`), so a re-run from
any directory finds it. `--state-dir` still overrides.
- `send_path` now persists a checkpoint as files complete (throttled to
at most once every 2s, written off the async runtime), so an abrupt
kill — not just a recoverable network error — leaves resumable state.

### Fixed — 2026-06-03 — PR #4 review (auto-resume)
- **Chunk-size mismatch no longer corrupts resume.** Resume detection now
also requires the saved `config.chunk_size` to match the current
invocation's; a re-run with a different `--chunk-size` (whose `.partial`
layout is incompatible) starts a fresh transfer with a warning instead
of skipping or overwriting the wrong byte ranges.
- **Stale duplicate state files are cleaned up.** When more than one
checkpoint matches the same source and peer, `send` resumes the newest
and deletes the older duplicates immediately, so a later identical
`send` can't pick up a stale checkpoint after the chosen one completes.
- **Checkpoint write is no longer O(files²) / blocking.** The per-file
checkpoint is throttled (≤ once per 2s), serialized compactly, and
written via `spawn_blocking` with an atomic temp+rename, instead of a
full pretty-JSON serialize and blocking `std::fs::write` on a Tokio
worker after every completed file.

### Fixed — 2026-05-23 — Security & robustness audit (16 findings)

Landed all 16 findings from a code review on the `quic` branch (4
Expand Down
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ anyhow = "1.0"
# punch primitives directly (no STUN, no NAT — pure localhost smoke).
p2p-rendezvous = { path = "./p2p-rendezvous" }
# Used by tests/rendezvous_disconnect_resume_test.rs to exercise the
# CLI-level send/receive/resume handlers end-to-end through a localhost
# CLI-level send/receive handlers end-to-end through a localhost
# rendezvous.
p2p-cli = { path = "./p2p-cli" }
tokio = { version = "1.40", features = ["full"] }
tempfile = "3.12"
sha2 = "0.10"
# Capture the "Resuming transfer" log line emitted by handle_send so the
# disconnect-resume test can assert phase 2 resumed rather than restarted.
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }

[features]
# Default: CLI only (small binary, ~5-10 MB)
Expand Down
22 changes: 17 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,23 @@ discovery toggle use this to pick the first responding peer.

## Resume

Chunk-level resume uses `state::TransferState` (a `BitVec` of completed
chunk indices per file) persisted to JSON. `P2PSession::send_path` loops
on a recoverable error (network/timeout/QUIC), re-establishes the
connection via `reconnect()`, and re-runs the folder send — which skips
any chunk index already in the bitmap.
Resume state is a `FolderTransferState` (the per-file completed-chunk
bitmap, the completed-files index, and a snapshot of the negotiated
`ConfigMessage`) persisted to `transfer_<uuid>.json`. The file also
records the **peer fingerprint** it was negotiated with. `send_path`
persists a fresh checkpoint each time a file completes (so an abrupt
kill still leaves resumable state) and on every recoverable-error retry;
on retry it re-establishes the connection via `reconnect()` and re-runs
the folder send, skipping any chunk index already in the bitmap.

There is no `resume` subcommand. Re-running the identical `send` finds
the prior state by `(peer_fingerprint, file list)` rather than by UUID:
`p2p-cli`'s `find_resumable_state` enumerates the source as a fresh send
would, then scans the per-user state dir for a `transfer_*.json` whose
stamped peer matches and whose recorded `(path, size, mtime)` list
matches the source exactly. A strict match resumes; any drift (or
`--no-resume`) starts fresh. State files live in a per-user data dir by
default so a re-run from any working directory still finds them.

## Bandwidth

Expand Down
39 changes: 9 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ NATs. Ships with a CLI and an optional Iced GUI.
* **Relay fallback** — symmetric NATs that can't be punched directly
fall through to a UDP forwarder; QUIC TLS still terminates
end-to-end (the relay sees ciphertext only).
* **Resume** — chunk-level bitmap persisted per transfer; reconnects
pick up where they left off. Chunk indices are `u64` end-to-end —
very large files transfer correctly.
* **Resume** — interrupted transfers are persisted per (peer, source);
re-running the same `send` automatically continues where it left off
(`--no-resume` forces a fresh start). Chunk indices are `u64`
end-to-end — very large files transfer correctly.
* **Integrity** — per-file SHA-256 exchanged both ways; receiver
mismatch is a hard failure (no silent acceptance).
* **Path safety** — every incoming relative path is sanitized; the
Expand Down Expand Up @@ -72,6 +73,11 @@ p2p-transfer send ./bigfile.bin \
`--peer-fingerprint` is required and is the 64-hex-char SHA-256 of the
receiver's cert (printed when the receiver starts up).

If a previous `send` of the same source to the same peer was interrupted,
`send` automatically picks up where it left off; pass `--no-resume` to
force a fresh transfer. Resume state lives in a per-user directory by
default (override with `--state-dir`).

### Send (LAN auto-discovery)

```
Expand Down Expand Up @@ -203,33 +209,6 @@ installed copy and only restarts the service when it actually changed, so
no-op re-runs don't interrupt active pairings. A `clean-build` + later
`install` works fine — cargo just rebuilds `target/` from scratch.

### Resume

`resume` accepts the same pairing flags as `send`/`receive` — either
direct addressing or rendezvous-mediated. Pick whichever matches how the
original `send` reached the peer.

```
# Direct (same LAN, or a stable port-forwarded receiver)
p2p-transfer resume <transfer_id> \
--path ./bigfile.bin \
--peer 192.168.1.42:14567 \
--peer-fingerprint <hex>

# Cross-NAT (the receiver is still listening through the same rendezvous + code)
p2p-transfer resume <transfer_id> \
--path ./bigfile.bin \
--rendezvous rendezvous.example.com:14570 \
--code ABC123
```

Reads `transfer_<transfer_id>.json` (written when a transfer is
interrupted) and continues from the chunk bitmap. The state file lives
in the working directory where the transfer started — pass
`--state-dir` if you started the original `send` from somewhere else.
The original `--path` and pairing flags aren't stored, so you have to
supply them again on resume.

### History

```
Expand Down
10 changes: 8 additions & 2 deletions p2p-cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ If you add a new command, add it to the `Commands` enum in `cli.rs` and its matc
src/
├── lib.rs # run_cli_sync, run_cli_async, init_logging
├── cli.rs # clap definitions: Cli, Commands, SessionParams, TransferParams
├── send.rs # handle_send
├── send.rs # handle_send (auto-resumes a prior interrupted send)
├── receive.rs # handle_receive
├── discover.rs # handle_discover
├── nat_test.rs # handle_nat_test
├── resume.rs # handle_resume
├── util.rs # base-name + resume-state location (find_resumable_state)
└── history.rs # handle_history
```

There is no `resume` command. A re-run of `send` finds a prior incomplete
transfer via `util::find_resumable_state` — matching the source against a
`transfer_*.json` by `(peer fingerprint, file list)` — and continues it.
`--no-resume` forces a fresh transfer; `--state-dir` overrides the
per-user default state location.

Each command module exposes a single `handle_*` entry point taking the parsed args. Keep CLI translation (prompts, progress bars, formatting) in these files; push protocol/transfer logic into `p2p-core`.

## Shared arg groups
Expand Down
1 change: 1 addition & 0 deletions p2p-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
console = "0.15"
dialoguer = "0.11"
chrono = "0.4"
directories = "5"

[dev-dependencies]
tempfile = "3.12"
Expand Down
44 changes: 12 additions & 32 deletions p2p-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,21 @@ pub enum Commands {
/// File or folder to send
path: PathBuf,

/// Directory to write the resume state file into. Defaults to the
/// current working directory. Pass an absolute path here so
/// `p2p-transfer resume <id> --state-dir <same>` works regardless
/// of where the user runs the resume command from.
/// Directory holding resume state files. Defaults to a per-user
/// location (`%APPDATA%\p2p-transfer\state` on Windows,
/// `$XDG_DATA_HOME/p2p-transfer/state` on Linux,
/// `~/Library/Application Support/p2p-transfer/state` on macOS) so
/// a re-run from any working directory finds a prior incomplete
/// transfer. Override only if you want the state kept elsewhere.
#[arg(long)]
state_dir: Option<PathBuf>,

/// Force a fresh transfer even if a matching incomplete transfer
/// to the same peer exists. Use when the source content changed in
/// a way the size+mtime resume check can't detect.
#[arg(long)]
no_resume: bool,

#[command(flatten)]
session: SessionParams,

Expand Down Expand Up @@ -213,34 +221,6 @@ pub enum Commands {
rendezvous: Option<String>,
},

/// Resume a previous transfer
///
/// Reconnects to the original receiver and continues from the last
/// persisted chunk boundary. Use the same pairing flags you used for
/// the original `send`: either `--peer` + `--peer-fingerprint` (direct
/// mode) or `--rendezvous` + `--code` (cross-NAT).
Resume {
/// Transfer ID to resume (or state file path)
transfer_id: String,

/// Original file or folder path to resume from
#[arg(long)]
path: PathBuf,

/// Directory the resume state file lives in. Must match whatever
/// `--state-dir` the original `send` used; defaults to the current
/// working directory.
#[arg(long)]
state_dir: Option<PathBuf>,

/// Max reconnect attempts after a connection drop (0 = retry forever)
#[arg(long, default_value = "5")]
max_reconnect_attempts: u32,

#[command(flatten)]
session: SessionParams,
},

/// View transfer history
History {
/// Show only recent N transfers
Expand Down
24 changes: 3 additions & 21 deletions p2p-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
//! - `send`: Send operations for files and folders
//! - `receive`: Receive operations
//! - `discover`: Peer discovery functionality
//! - `resume`: Resume interrupted transfers

// `cli`, `send`, `receive`, and `resume` are `pub` so the workspace-level
// `cli`, `send`, and `receive` are `pub` so the workspace-level
// integration test in `tests/rendezvous_disconnect_resume_test.rs` can
// drive the same handler functions the binary dispatches to. The rest
// stay private — they're not stable surface for external consumers.
Expand All @@ -17,7 +16,6 @@ mod history;
mod nat_test;
pub mod receive;
mod rendezvous;
pub mod resume;
pub mod send;
mod util;

Expand Down Expand Up @@ -132,10 +130,11 @@ async fn run_cli_async(cli: Cli) -> Result<()> {
Some(cli::Commands::Send {
path,
state_dir,
no_resume,
session,
transfer,
}) => {
send::handle_send(path, state_dir, session, transfer, identity_dir).await?;
send::handle_send(path, state_dir, no_resume, session, transfer, identity_dir).await?;
}
Some(cli::Commands::Receive {
output,
Expand All @@ -153,23 +152,6 @@ async fn run_cli_async(cli: Cli) -> Result<()> {
}) => {
nat_test::handle_nat_test(stun_server, rendezvous).await?;
}
Some(cli::Commands::Resume {
transfer_id,
path,
state_dir,
max_reconnect_attempts,
session,
}) => {
resume::handle_resume(
transfer_id,
path,
state_dir,
max_reconnect_attempts,
session,
identity_dir,
)
.await?;
}
Some(cli::Commands::History {
limit,
direction,
Expand Down
Loading
Loading