Author SHA1 Message Date
molluskandClaude Fable 5 e6eb490939 audio: only FEC-recover a gap from its immediate successor packet
CI / check (push) Failing after 10m54s
The jitter buffer's gap path fed the LOWEST buffered packet to
decode_fec regardless of position. Opus in-band FEC in packet N carries
a copy of frame N-1 and nothing else, so that reconstruction is only
correct when the smallest survivor is exactly next+1 (single loss).
On burst loss it spliced a later frame's audio into the wrong slot —
worse than concealment. Gate FEC on adjacency (new fec_covers_gap(),
wraparound-aware); everything else falls back to plain PLC.

Two new tests: the gate itself, and a burst-loss test proven to bite —
it compares bit-exact against a twin decoder and fails against the old
unconditional-FEC behavior (checked by mutation).

Fixes finding 3 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:31:47 -04:00
molluskandClaude Fable 5 e724167b03 docs: add chat-hardening plan as scope contract
GPT-5.6's 5-phase plan for the chat identity/replay/rate-limit cluster
(2026-07-16 review findings 5-8): roster-bound display names, replay
dedup, quiet rate limiting, bidi-aware sanitization, attachment size
checks. Self-describes as temporary — delete when the work completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:27:47 -04:00
molluskandClaude Fable 5 7b9cb57003 core: survive a failed net-stack rebuild instead of silently dying
CI / check (push) Failing after 13m37s
The four live-rebuild sites (deferred rebuild on Join/Leave, idle
SetNetworkMode, idle RegenerateIdentity) all did
`net.shutdown().await` then `build_net_stack(...).await?` — a build
failure propagated out of run_core_loop, which its supervisor only
logs. Every subsequent command went nowhere: window alive, app dead,
user told nothing. (The initial startup build already reported.)

New replace_net_stack() helper: tear down the old stack, build for the
requested posture, and on failure fall back to the posture the old
stack was actually running (tracked in the new `net_mode` local; when
the postures are equal the fallback is a plain retry — e.g. identity
regeneration, where reverting the already-persisted key would be
wrong). If the fallback lands, the UI is told the change didn't stick
and `network_mode` reverts so state stays honest and the change stays
re-attemptable. If both builds fail the UI gets a fatal 'Networking
lost … restart' error before the loop exits — informed, not a zombie.

Retry policy isolated in rebuild_with_fallback(), generic over the
builder: 4 new unit tests cover first-try success, fall-back, plain
retry, and double failure without binding sockets.

Fixes finding 2 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:58:58 -04:00
molluskandClaude Fable 5 af7a42a049 ci: remove zombie workflows from the per-push pipeline
cargo-deny.yml (runs-on: ubuntu-latest) and windows-build.yml (runs-on:
windows-latest) target runner labels no registered runner advertises, so
every push queued two runs Gitea auto-cancelled ~24h later — the Actions
page has shown 2 cancelled runs per push since the runner went live.

- cargo-deny.yml: deleted; redundant with ci.yml's deny step, which now
  runs `cargo deny --locked check` to preserve the locked-tree stance.
- windows-build.yml: kept but workflow_dispatch-only until a Windows
  runner exists; restore instructions in the header comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:56 -04:00
molluskandClaude Fable 5 e78e7bc2a5 supply-chain: ignore quick-xml build-time DoS advisories + ttf-parser unmaintained
RUSTSEC-2026-0194/0195 (quick-xml 0.39.4, published 2026-06-29) broke the
deny/audit CI gates on every push since June 29. quick-xml is reached only
via the wayland-scanner proc-macro parsing vendored protocol XML at compile
time — attacker input never touches it and it is absent from the shipped
binary. The fixed 0.41.0 is semver-incompatible with wayland-scanner's
`^0.39` req (no upstream bump yet); documented ignores until one exists.

RUSTSEC-2026-0192 (ttf-parser unmaintained, via iced/cosmic-text) joins the
existing unmaintained ignores (paste, audiopus_sys) — same class, same
lockfile-pinning protection.

New .cargo/audit.toml keeps cargo-audit in sync with deny.toml.

Known leftover warning (allowed, non-failing): spin 0.10.0 is yanked but
futures-buffered (via iroh) requires ^0.10 and no unyanked 0.10.x exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:13 -04:00
molluskandClaude Fable 5 52ab374b74 style: cargo fmt under rustfmt 1.9.0 (toolchain update 2026-07-08)
Six diffs across four files: the 2026-07-08 stable toolchain update
(rustc 1.96.1 / rustfmt 1.9.0) re-flags code that was fmt-clean when
committed under the previous rustfmt. No semantic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:45:18 -04:00
mollusk 8825707c17 chore: patch crossbeam-epoch RustSec advisory
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-07-15 06:31:09 -04:00
13 changed files with 791 additions and 91 deletions
+11
View File
@@ -0,0 +1,11 @@
# cargo-audit configuration. Keep the ignore list in sync with deny.toml,
# which carries the full justification for each entry.
[advisories]
ignore = [
# quick-xml DoS advisories: build-time only, reached solely via the
# wayland-scanner proc-macro parsing trusted vendored protocol XML.
# Fix (0.41.0) is semver-incompatible with wayland-scanner's `^0.39`;
# drop once wayland-scanner bumps. See deny.toml.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
-34
View File
@@ -1,34 +0,0 @@
name: cargo-deny
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
# sources) on every push to main and every PR. Runs on a *locked* tree so the
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
# cannot reach CI until Cargo.lock is deliberately updated.
on:
push:
branches: [main]
pull_request:
jobs:
cargo-deny:
runs-on: ubuntu-latest
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
# `cargo metadata`. Adjust the runner label if your act_runner uses a
# different one.
container: rust:1
steps:
- uses: actions/checkout@v4
- name: Install cargo-deny (pinned prebuilt)
run: |
set -euo pipefail
version=0.19.9
curl -sSfL \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
cargo-deny --version
- name: cargo deny check
run: cargo deny --locked check
+3 -1
View File
@@ -36,7 +36,9 @@ jobs:
run: cargo test --doc
- name: cargo-deny (advisories, bans, licenses, sources)
run: cargo deny check
# --locked so the pinned, vetted versions in Cargo.lock are exactly
# what get audited (the lockfile-as-review-checkpoint model).
run: cargo deny --locked check
- name: cargo-audit
run: cargo audit
+15 -11
View File
@@ -7,11 +7,20 @@ name: windows-build
# alias) so a Unix-only assumption can't sneak back in and break Windows.
#
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
# runner advertises a different label, change `runs-on` below. Until a Windows
# runner exists this workflow is simply skipped/queued, not a failure of the
# Linux CI.
# `windows-latest` label (a Linux-container approach does NOT apply here —
# Windows jobs run on the host, not a Linux container). If your runner
# advertises a different label, change `runs-on` below.
#
# MANUAL-ONLY until that runner exists: with push/PR triggers enabled, every
# push queued a run no runner could claim and Gitea auto-cancelled it ~24h
# later, littering the Actions page with cancelled runs. Restore the push/PR
# triggers when a Windows runner is registered:
#
# on:
# push:
# branches: [main, "windows-port-**"]
# pull_request:
# workflow_dispatch:
#
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md):
@@ -23,12 +32,7 @@ name: windows-build
# must provide both.
on:
push:
# `main` plus the in-progress port branches, so the Windows path is exercised
# before merge rather than only after.
branches: [main, "windows-port-**"]
pull_request:
# Allow manual runs from the Gitea Actions UI.
# Manual runs from the Gitea Actions UI only — see the header comment.
workflow_dispatch:
permissions:
Generated
+2 -2
View File
@@ -1207,9 +1207,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
+13
View File
@@ -24,6 +24,19 @@ ignore = [
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
"RUSTSEC-2026-0150",
# ttf-parser: unmaintained, transitive via iced/cosmic-text (font parsing
# for the GUI). Inputs are system + embedded fonts, not network data. No
# upstream migration yet; revisit when iced moves off it.
"RUSTSEC-2026-0192",
# quick-xml 0.39.4 DoS advisories (quadratic dup-attr check; unbounded
# namespace allocation). Build-time only: quick-xml is reached solely via
# the wayland-scanner PROC-MACRO, which parses the wayland protocol XML
# files vendored inside the wayland-* crates at compile time. Attacker
# input never reaches it and it is not in the shipped binary. The fix
# (0.41.0) is semver-incompatible with wayland-scanner 0.31.x's `^0.39`
# requirement; drop both ignores once wayland-scanner releases a bump.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
# ---------------------------------------------------------------------------
+385
View File
@@ -0,0 +1,385 @@
# Chat hardening — ephemeral implementation plan
**Status (2026-07-15):** PLANNED, not started. This is a temporary scope
contract for hardening the existing room chat. Update the checkboxes and decision
log as work lands, then delete this file when the work is complete. Do not add
link previews as part of this effort.
## Goal
Strengthen the current encrypted, signed, session-only room chat without changing
its product model: plain selectable text, clickable web links, and peer-to-peer
attachments over the existing gossip and files planes. The work should make chat
resistant to identity spoofing, replay, spam, oversized input, expensive rendering,
and attachment-driven memory/bandwidth pressure while preserving normal Unicode
conversation and the existing full-mesh architecture.
## Existing foundation to preserve
- Gossip payloads are signed by the claimed `EndpointId`, bound to the raw room
topic and protocol domain, and checked before dispatch.
- The signed envelope timestamp is admitted only within the two-minute gossip
freshness window.
- Inbound gossip frames are capped at 128 KiB before JSON deserialization. This
larger plane-wide cap must remain because `Announce` may contain a custom avatar.
- Chat history is session-only and capped at 300 entries.
- Only `http://` and `https://` links are opened, as a single process argument
without a shell.
- Attachment descriptors are signed with the chat payload; attachment bytes use
the encrypted files plane, have a 25 MiB per-file cap, and are keyed by both
author and attachment id.
- Image bytes are decoded defensively and automatic image fetches already have a
four-task concurrency limit.
## Working design decisions
These are the implementation defaults unless code inspection or tests reveal a
concrete reason to adjust them. Record any adjustment in the decision log.
1. **No wire change.** Keep `GossipMessage::Chat` unchanged and do not bump
`GOSSIP_PROTO`. The redundant wire `name` and inner `Chat.ts` remain serialized
for compatibility but are not trusted. Remove them only during a future planned
gossip-version bump.
2. **Roster identity is authoritative.** A chat line is admitted only for an
authenticated identity already known to the current room (including the
reconnect grace state). Its displayed name comes from the sanitized roster
state, never from `GossipMessage::Chat.name`.
3. **Body Unicode remains expressive.** Do not apply the short-label sanitizer to
the message body; it strips format characters used by some languages and emoji.
Continue neutralizing controls and whitespace, while treating author labels,
filenames, and URLs more strictly because those are spoof-sensitive surfaces.
4. **Bounds apply at every trust boundary.** UI input is bounded while editing,
outgoing text is normalized before signing, and incoming text is byte-checked
and normalized before it leaves the gossip layer. UI-only truncation is not an
adequate ingress defense.
5. **Automatic network work is stricter than manual work.** Keep the 25 MiB manual
attachment ceiling, but auto-fetch only small images. Larger images remain
available behind an explicit Load/Download action.
6. **Caches are bounded by cost, not only entry count.** Count encoded bytes and
estimated decoded image bytes. A count cap remains as a secondary bound.
7. **Rate limiting degrades quietly.** Drop excess/replayed peer messages with a
rate-limited log entry. Do not let a spammer produce a second UI-notification
flood.
## Proposed policy constants
Keep these together near the code that enforces them and cover them with boundary
tests. Values are starting points, not a compatibility contract.
| Policy | Initial value | Reason |
| --- | ---: | --- |
| Chat body characters | 2,000 | Preserves current UI behavior |
| Chat body UTF-8 bytes | 8 KiB | Covers 2,000 four-byte scalars with small headroom |
| Live input characters/bytes | Same as body | Prevent oversized paste/edit state |
| Clickable links per message | 8 | Bounds spans and opener targets |
| Retained chat text | 512 KiB plus 300 entries | Bounds redraw and selection work |
| Per-author chat limiter | Burst 8, refill 1/second | Allows normal bursts, stops sustained spam |
| Room-wide chat limiter | Burst 32, refill 8/second | Protects shared event/UI queues |
| Exact-chat replay cache | 1,024 digests, 2-minute TTL | Covers freshness window with a hard bound |
| Auto-fetch image encoded size | 4 MiB | Limits unsolicited bandwidth and allocations |
| Attachment cache encoded budget | 128 MiB | Allows several ordinary files without GiB growth |
| Attachment cache decoded-preview budget | 64 MiB | Bounds renderer-side image pressure |
| Served attachment budget | 256 MiB plus a count cap | Bounds sender memory for a long session |
| Inline preview longest side | 1,600 px | Chat renders near 260 px; full 4K decode is wasteful |
| Decoded source image pixels | 16 megapixels maximum | Adds a total-pixel bound to per-side bounds |
## Phase 1 — Shared text policy and live-input bounds
**Target:** downstream layers never receive or retain an unexpectedly large or
unsafe chat string.
- [ ] Move chat constants and `sanitize_chat` from `src/app/mod.rs` into
`src/sanitize.rs` (or a narrowly scoped shared chat-policy module if that keeps
the API clearer).
- [ ] Implement a single-pass sanitizer that:
- maps control characters to spaces;
- collapses whitespace and trims ends;
- enforces both the character and UTF-8 byte ceilings without splitting a scalar;
- returns empty for content with no visible text.
- [ ] Add `cap_chat_input` for live editing. It must preserve the user's current
whitespace while enforcing character and byte ceilings; normalization remains a
submit/ingress operation so typing does not visibly jump.
- [ ] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
clipboard, primary-selection, and context-menu paste paths through the controlled
input widget.
- [ ] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
- [ ] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
caller cannot bypass policy.
- [ ] At gossip ingress, reject raw chat text over the byte ceiling before doing
downstream sanitization; sanitize accepted text before creating `RoomEvent`.
- [ ] Keep attachment-only messages when the sanitized caption is empty; drop a
chat with neither visible text nor a valid attachment.
- [ ] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
replaces it with the roster-bound name.
### Phase 1 tests
- [ ] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
- [ ] Exact character and byte boundaries, including a four-byte scalar at the
cutoff.
- [ ] Oversized paste never makes `state.chat_input` exceed either ceiling.
- [ ] Outgoing, incoming, and direct core/network paths converge on the same
normalized result.
- [ ] Empty captions are retained only when a valid attachment remains.
## Phase 2 — Admission, identity binding, replay, and spam control
**Target:** only current authenticated room members can create chat UI work, and a
member cannot impersonate another participant or monopolize the control/UI queues.
- [ ] Change the core event task's chat roster from a bare `HashSet<EndpointId>` to
a bounded map containing each member's latest sanitized display name (or retain a
parallel name map if less invasive).
- [ ] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
reconnect grace, and remove it on graceful or terminal eviction.
- [ ] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
whose author is not present in that authoritative roster.
- [ ] Replace the embedded wire name with the roster map's name before constructing
`UiEvent::ChatMessage`. The UI may keep storing a name snapshot so old chat lines
remain labeled after a peer leaves.
- [ ] Add a lightweight early known-author gate in the gossip loop using its live
and disconnected-peer sets. Keep the core roster gate as defense in depth and as
the final authority.
- [ ] Validate that the inner `Chat.ts` equals the signed envelope timestamp, or
ignore it entirely. Do not use the inner timestamp for replay or ordering.
- [ ] Add exact-chat replay suppression after signature verification and before
event-channel send:
- hash the canonical signed bytes, not raw JSON formatting;
- use BLAKE3 (make it a direct dependency if needed; it is already in the iroh
dependency graph) or an equally collision-resistant existing primitive;
- store a `HashSet` plus FIFO/TTL order for bounded lookup and eviction;
- prune by both the gossip freshness window and the hard entry cap.
- [ ] Add a bounded token bucket per admitted author and a room-wide bucket before
awaiting `event_tx.send`. Limiter state must be removed with roster eviction and
remain bounded by the roster cap.
- [ ] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
a replay cannot starve a legitimate new message from that author.
- [ ] Rate-limit rejection logging per author/reason.
- [ ] Consider applying the same local submit policy to accidental rapid Enter or
button activation, without routing chat through the coalescing command path.
### Phase 2 tests
- [ ] Valid roster author is admitted; never-announced, post-leave, forged, and
stale authors are rejected.
- [ ] A peer sending `name = "Victim"` renders under its own roster name.
- [ ] A name update affects future messages without rewriting history.
- [ ] Reconnect grace continues accepting the known author; terminal eviction does
not.
- [ ] The same signed chat is displayed once; distinct chats created in the same
millisecond are both admitted.
- [ ] Replay-cache TTL/cap pruning cannot grow without bound.
- [ ] Per-author burst/refill and room-wide burst/refill boundaries.
- [ ] Excess chat cannot prevent a subsequent `Leave` or `Announce` from reaching
the event loop in a deterministic channel-pressure test.
## Phase 3 — Attachment transfer and memory hardening
**Target:** neither peers nor long local sessions can turn chat attachments into
unbounded memory, bandwidth, decoder, or task pressure.
### 3A. Cache and image cost
- [ ] Extend `AttachmentCache` with encoded-byte and decoded-preview-byte counters.
Preserve the count cap, but evict oldest entries until all three budgets fit.
- [ ] Give every entry an explicit weight. Replacement must subtract the old
weight before checking/inserting the new one.
- [ ] Decide behavior for a single entry larger than the cache budget: service an
immediate pending Save/Play request without retaining it, then expose it as
evicted/unavailable rather than exceeding the budget.
- [ ] Add a total-pixel limit to `validate_image_bytes` in addition to the existing
width/height limit.
- [ ] Build a downscaled inline preview handle with a maximum 1,600 px side. Keep
original bytes only for Save; do not hand a full-resolution 4K image to the
renderer merely to display it at chat width.
- [ ] Count estimated RGBA preview cost (`width * height * 4`) against the decoded
budget even if iced internally copies or uploads it.
- [ ] Strip the same bidi/zero-width spoofing characters used for display labels
from attachment filenames, while preserving ordinary Unicode filenames.
### 3B. Automatic download policy and state
- [ ] Auto-fetch only roster-authored images whose declared size is at or below
`MAX_AUTO_IMAGE_BYTES`; keep the existing `(author,id)` dedup and four-permit
concurrency bound.
- [ ] Add per-author and session byte/request budgets for automatic fetches so a
peer cannot drain bandwidth sequentially after each permit is released.
- [ ] Represent `NotFetched`, `Loading`, `Ready`, `Failed`, and `Evicted` distinctly
enough for the UI to avoid an indefinite “loading…” label when auto-fetch was
skipped or the cache evicted an item.
- [ ] Render a Load image button for large/skipped images. A manual click may use
the 25 MiB file cap but still observes cache/decoder budgets.
- [ ] Ensure a repeated click cannot create duplicate unguarded fetch tasks.
- [ ] Keep non-image attachments manual-only.
### 3C. Exact transfers, local reads, and served files
- [ ] In `IrohTransport::fetch_blob`, require `bytes.len() as u64 == declared_size`.
Reject empty, short, and overlong transfers with a concise local error.
- [ ] Replace the file picker's unbounded `FileHandle::read()` with a helper that
reads at most `MAX_ATTACHMENT_BYTES + 1`. Check metadata first where available,
but retain the bounded read because metadata can race or be unavailable through
a portal.
- [ ] Avoid duplicating a full attachment across UI, command queue, and serve store.
Prefer `Arc<Vec<u8>>`/`Arc<[u8]>` through `AttachmentState`, `CoreCommand`, and
`serve_attachment`, subject to iced handle API constraints.
- [ ] Replace the unbounded session `served_files` map with a count- and byte-
budgeted FIFO store. Evicted ids should produce the existing “sender no longer
has the file” response rather than stale or aliased data.
- [ ] Keep attachment ids keyed by author on receipt and preserve all existing
request-length, timeout, filename, and decoder checks.
### Phase 3 tests
- [ ] Byte-budget eviction, count eviction, replacement accounting, clear/reset,
and an individually overweight entry.
- [ ] Decoded-preview budget and downscale dimensions for wide, tall, square, and
boundary images.
- [ ] Image with valid per-side dimensions but excessive total pixels is rejected.
- [ ] A declared 4 MiB image auto-fetches; the first byte over the limit requires a
click.
- [ ] Per-author/session auto-fetch budgets recover according to their policy and
never exceed task concurrency.
- [ ] Short, exact, and overlong file responses.
- [ ] Local file reader stops at cap + 1 instead of allocating the full source.
- [ ] Served-file FIFO/byte eviction and replacement accounting.
- [ ] Same attachment id from two authors remains isolated throughout fetch, cache,
save, and display.
## Phase 4 — URL and rendering resilience
**Target:** keep clickable links without making malformed/deceptive input or many
small spans an unnecessary UI/launcher surface.
- [ ] Make `url` a direct dependency (already present transitively) and validate
link candidates with `url::Url`.
- [ ] A clickable URL must have an `http` or `https` scheme and a valid host.
- [ ] Treat URLs containing username/password syntax as plain text, or require an
explicit confirmation that shows the parsed destination host. Prefer plain text
for the first implementation.
- [ ] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
replace prefix checks with the shared parsed-URL policy.
- [ ] Cap clickable candidates at eight per message. Remaining content stays
selectable plain text and must still round-trip exactly.
- [ ] Refactor linkification to return borrowed ranges/offsets or cache link ranges
in `ChatEntry`, avoiding allocation and rescanning on every redraw.
- [ ] Bound retained history by total sanitized text bytes as well as 300 entries.
Eviction must keep attachment bookkeeping coherent and should not invalidate an
open Save/Play operation.
- [ ] Do not add metadata fetching, remote images, Markdown, or link previews.
### Phase 4 tests
- [ ] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
punctuation, credentials/userinfo, and non-web schemes.
- [ ] Eight-link boundary and many-link adversarial input.
- [ ] Segment/range reconstruction exactly reproduces the sanitized message.
- [ ] Entry-count and total-text-budget history eviction.
- [ ] Opener policy cannot launch a non-web scheme even if called directly.
## Phase 5 — Honest local send status
**Target:** never present a locally echoed message as successfully broadcast when
the core rejected it or gossip broadcast failed.
- [ ] Add a local-only message id and `Pending`/`Broadcast`/`Failed` state to local
chat entries. Do not put this id or state on the wire.
- [ ] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a
`UiEvent` result after the local gossip broadcast call succeeds or fails.
- [ ] If the core is not in an active session, return failure instead of silently
doing nothing.
- [ ] Show failure compactly with a retry action. A successful local broadcast must
not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements.
- [ ] Retry creates one new signed broadcast while retaining replay correctness and
attachment serving state.
### Phase 5 tests
- [ ] Local echo starts pending, becomes broadcast on success, and becomes failed
on no-session/channel/gossip error.
- [ ] Results update only the matching local entry, including after history
eviction or room reset.
- [ ] Retry does not duplicate served bytes or mutate an unrelated entry.
## Compatibility and versioning
- The planned implementation changes validation, local data structures, and
internal `CoreCommand`/`UiEvent` shapes only. Keep the serialized
`GossipMessage::Chat` and file request/response formats unchanged.
- Therefore do **not** bump `GOSSIP_PROTO`, `FILES_PROTO`, or the pre-1.0 MINOR
solely for this plan. The eventual release is a compatible PATCH unless scope
expands into a wire change.
- If implementation requires removing/adding serialized fields, changing
attachment request framing, or introducing acknowledgements on the wire, stop
and revise this section before coding that part. Follow `VERSIONING.md` and use
the appropriate protocol plus release MINOR bump.
## Verification gates
Run after each phase, with focused tests first and the full gates before handoff:
```text
cargo fmt --check
cargo test --lib
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
```
Also retain the existing ignored/loopback coverage where the environment supports
it; do not make ordinary unit tests depend on external network access.
### Two-machine field test
- [ ] Ordinary ASCII/Unicode conversation, rapid short burst, long boundary text,
and oversized paste.
- [ ] Rename during a room: new lines use the new roster name; old lines retain
their snapshot.
- [ ] Disconnect/reconnect grace and post-leave chat admission behavior.
- [ ] Multiple normal images, one image above the auto threshold, a malformed
“image”, and a maximum-size manual file.
- [ ] Download/save after cache eviction; clear failure state and no runaway
memory across repeated attachments.
- [ ] Observe process RSS and UI responsiveness during a bounded spam/attachment
stress run; verify leave/reconnect controls remain responsive.
- [ ] Linux and Windows URL opening for valid links; malformed/userinfo links remain
selectable but do not launch.
## Completion criteria
The plan is complete when:
1. Only active/grace-rostered authenticated authors reach chat UI state.
2. Chat identity is roster-bound and cannot be overridden by the embedded wire
name.
3. Exact replay and sustained spam are bounded before shared event queues.
4. Live input, inbound/outbound body size, history text, attachment caches,
automatic transfers, served files, and decoded previews all have tested hard
bounds.
5. File transfer length and image decoding/display costs are validated.
6. Clickable links pass a shared parsed-URL policy and rendering work is bounded.
7. Local broadcast failure is visible without claiming peer delivery.
8. Unit/all-target/clippy gates and the two-machine field test pass.
9. Relevant durable docs (`README.md`, `docs/FEATURES.md`, `CHANGELOG.md`, security
notes, and comments) describe the final behavior.
10. This ephemeral plan is deleted after its useful status/history is transferred
to durable documentation.
## Out of scope
- Link previews, metadata fetches, or remote thumbnail requests.
- Persistent/offline chat history or server-side message storage.
- Markdown, rich embeds, reactions, editing, deletion, threads, or search.
- Read receipts or peer delivery acknowledgements.
- Moderation UI, kicking, blocking, or trust-list redesign.
- Antivirus/malware scanning of user-requested downloaded files.
- A new application-layer group-encryption protocol or a broader cryptographic
redesign. If PeerSpeak makes a formal end-to-end-encryption product claim, audit
and document the exact iroh/gossip/relay threat model as a separate project.
## Decision log
- **2026-07-15:** Chose hardening over automatic link previews because receiving a
message should not trigger third-party web requests or weaken PeerSpeak's
privacy-oriented design.
- **2026-07-15:** Initial scope keeps all wire formats stable; hardening is local
admission, validation, resource accounting, and honest UI state.
+13 -5
View File
@@ -6239,7 +6239,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// Rendered only while the audio link is live — the
// Connecting/Reconnecting indicator covers the rest.
if let Some(info) = state.conn_stats.get(peer_id) {
let dot_color = if info.relay { color_yellow } else { color_green };
let dot_color = if info.relay {
color_yellow
} else {
color_green
};
let badge = row![
text("").size(9).color(dot_color),
text(conn_badge_label(info)).size(11).color(color_subtext),
@@ -6248,7 +6252,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.align_y(iced::alignment::Vertical::Center);
let detail = column![
text(conn_tooltip_path(info)).size(11).color(color_text),
text(conn_loss_label(info.loss_pct)).size(11).color(color_subtext),
text(conn_loss_label(info.loss_pct))
.size(11)
.color(color_subtext),
text(format!(
"↑ {} ↓ {}",
conn_rate_label(info.up_kbps),
@@ -6261,9 +6267,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
name_col = name_col.push(
tooltip(
badge,
container(detail)
.padding(8)
.style(c_style(color_crust, color_surface, 6.0)),
container(detail).padding(8).style(c_style(
color_crust,
color_surface,
6.0,
)),
iced::widget::tooltip::Position::Bottom,
)
.gap(6),
+88 -3
View File
@@ -202,15 +202,20 @@ impl JitterBuffer {
None
} else {
// Gap with later packets already buffered: a packet was lost
// or reordered out of window. First try Opus in-band FEC from
// the next packet; if unavailable, fall back to plain PLC.
// or reordered out of window. Try Opus in-band FEC from the
// packet right after the gap; if that packet isn't buffered
// (burst loss) or FEC fails, fall back to plain PLC.
self.next_seq = Some(next.wrapping_add(1));
self.note_disruption();
let next_payload = self.packets.values().next().expect("non-empty");
let (&smallest, next_payload) = self.packets.iter().next().expect("non-empty");
if fec_covers_gap(next, smallest) {
self.decoder
.decode_fec(next_payload)
.or_else(|_| self.decoder.decode(None))
.ok()
} else {
self.decoder.decode(None).ok()
}
}
}
}
@@ -222,6 +227,15 @@ impl JitterBuffer {
}
}
/// Opus in-band FEC in packet N carries a low-fidelity copy of frame N-1 and
/// nothing else — a lost frame `next` is FEC-recoverable solely from packet
/// `next+1`. Any later successor's FEC data is a different frame's audio, and
/// splicing it into this gap plays sound from the wrong position; the caller
/// must conceal with plain PLC instead.
fn fec_covers_gap(next: u32, smallest_buffered: u32) -> bool {
smallest_buffered == next.wrapping_add(1)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -379,6 +393,77 @@ mod tests {
);
}
#[test]
fn fec_covers_gap_only_for_the_immediate_successor() {
// Packet next+1 is the only one whose in-band FEC describes frame `next`.
assert!(fec_covers_gap(4, 5));
// A burst gap: the smallest survivor's FEC is some other frame's audio.
assert!(!fec_covers_gap(3, 5));
assert!(!fec_covers_gap(3, 3_000));
// Sequence wraparound still counts as adjacent.
assert!(fec_covers_gap(u32::MAX, 0));
}
#[test]
fn burst_gap_falls_back_to_plc_not_wrong_position_fec() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
enc.apply_params(&OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 60,
dtx: false,
})
.unwrap();
// Frames 0..=6; 3 and 4 are lost as a burst, so when playout reaches
// seq 3 the smallest buffered packet is 5 — whose FEC data is frame 4,
// NOT frame 3. The buffer must conceal 3 with plain PLC rather than
// splice frame 4's audio into the wrong position.
let packets: Vec<Vec<u8>> = (0..7).map(|seq| tone_frame(&mut enc, 8_000, seq)).collect();
// Twin decoder replaying the exact call sequence the jitter buffer
// should make for seq 3: decode 0,1,2 then a plain PLC conceal.
let mut twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(3) {
twin.decode(Some(packet)).unwrap();
}
let expected_plc = twin.decode(None).unwrap();
let mut jb = JitterBuffer::new().unwrap();
for (seq, packet) in packets.iter().enumerate() {
if seq != 3 && seq != 4 {
jb.insert(seq as u32, packet.clone());
}
}
for _ in 0..3 {
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
}
// Seq 3: burst gap — bit-exact PLC (same decoder state, same inputs),
// which decode_fec(packet 5) could never produce.
let concealed = jb.pop_frame().expect("gap should be concealed");
assert_eq!(concealed, expected_plc, "burst gap must use plain PLC");
// Seq 4: packet 5 IS the immediate successor, so its FEC data is
// frame 4's audio — the correctly-positioned recovery still applies.
let recovered = jb
.pop_frame()
.expect("adjacent gap should be reconstructed");
let mut fec_twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(3) {
fec_twin.decode(Some(packet)).unwrap();
}
fec_twin.decode(None).unwrap();
let expected_fec = fec_twin.decode_fec(&packets[5]).unwrap();
assert_eq!(recovered, expected_fec, "adjacent gap should still use FEC");
// Then 5 and 6 play normally.
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
assert!(jb.pop_frame().is_none());
}
#[test]
fn drops_packets_already_played() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
+244 -28
View File
@@ -888,6 +888,103 @@ async fn build_net_stack(
})
}
/// Retry policy for a live net-stack replacement, generic over the builder so
/// it is unit-testable without binding sockets: build for `requested`; if that
/// fails, build for `live` (the posture the old stack was actually running) so
/// a bad posture change degrades to the previous posture instead of leaving no
/// stack at all. When `requested == live` the second attempt is a plain retry.
///
/// `Ok((stack, mode, primary_err))` — a stack is up on `mode`; `primary_err`
/// is `Some` when the first attempt failed. `Err((primary, fallback))` — both
/// attempts failed and networking is gone.
async fn rebuild_with_fallback<T, E, F, Fut>(
mut build: F,
requested: NetworkMode,
live: NetworkMode,
) -> Result<(T, NetworkMode, Option<E>), (E, E)>
where
F: FnMut(NetworkMode) -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
match build(requested).await {
Ok(stack) => Ok((stack, requested, None)),
Err(primary) => match build(live).await {
Ok(stack) => Ok((stack, live, Some(primary))),
Err(fallback) => Err((primary, fallback)),
},
}
}
/// Tear down `old` and stand up a replacement stack for `requested_mode`.
///
/// A build failure here is rare (only the local socket bind can fail; the
/// relay handshake is backgrounded), but it used to propagate straight out of
/// `run_core_loop` with no `UiEvent`, silently killing every future command —
/// the app looked alive and did nothing. Instead, fall back to `live_mode`
/// via `rebuild_with_fallback`, tell the UI when the requested change did not
/// stick, and return the mode the new stack actually runs so the caller can
/// keep its state honest. `Err` only when both builds fail: networking is
/// gone (already reported to the UI as fatal) and the caller should exit.
#[allow(clippy::too_many_arguments)]
async fn replace_net_stack(
old: NetStack,
what: &str,
secret_key: &SecretKey,
requested_mode: NetworkMode,
live_mode: NetworkMode,
friends_handler: &crate::presence_net::Handler,
publish: bool,
ui_tx: &mpsc::Sender<UiEvent>,
) -> Result<(NetStack, NetworkMode), anyhow::Error> {
let lookup = old.memory_lookup.clone();
old.shutdown().await;
let outcome = rebuild_with_fallback(
|mode| {
build_net_stack(
secret_key.clone(),
mode,
lookup.clone(),
friends_handler.clone(),
publish,
)
},
requested_mode,
live_mode,
)
.await;
match outcome {
Ok((stack, mode, None)) => Ok((stack, mode)),
Ok((stack, mode, Some(primary))) => {
if mode == requested_mode {
// Same-posture retry succeeded — everything the user asked for
// is in effect, so log it rather than raising a UI error.
crate::log_msg(&format!(
"{what}: net stack build failed once ({primary:#}); retry succeeded"
));
} else {
let _ = ui_tx
.send(UiEvent::Error(format!(
"{what} failed ({primary:#}); staying on the previous \
network mode for this session"
)))
.await;
}
Ok((stack, mode))
}
Err((primary, fallback)) => {
let _ = ui_tx
.send(UiEvent::Error(format!(
"Networking lost: {primary:#} (recovery attempt also failed: \
{fallback:#}). Restart PeerSpeak to reconnect."
)))
.await;
Err(anyhow::anyhow!(
"net stack rebuild failed: {primary:#}; fallback: {fallback:#}"
))
}
}
}
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
///
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
@@ -1335,6 +1432,10 @@ async fn run_core_loop(
// rebuilt on the next Leave (or before the next Join), preserving the old
// "applies on next join" semantics while keeping the endpoint up while idle.
let mut net_rebuild_pending = false;
// The posture the live stack was actually built with. Trails `network_mode`
// while a rebuild is pending, and is the fallback posture when a rebuild
// fails (see `replace_net_stack`).
let mut net_mode = network_mode;
// When Discoverable is on, the instant it auto-reverts to Normal (W7 P6 time-box).
// `None` = not Discoverable, no pending revert. Set on SetPresenceMode(Discoverable),
@@ -1552,17 +1653,24 @@ async fn run_core_loop(
// active, rebuild the persistent stack now — after the old session is
// gone, before the new one binds — so this join uses the new posture.
if net_rebuild_pending {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
let (stack, live) = replace_net_stack(
net,
"Applying deferred network settings",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
// If the new posture failed and we fell back, keep the mode
// state honest (and re-attemptable) rather than pretending
// the change applied. The join proceeds on the live stack.
network_mode = live;
net_rebuild_pending = false;
}
@@ -2594,17 +2702,21 @@ async fn run_core_loop(
// Apply any network-mode / identity change that was deferred while we
// were in the call (rebuild while idle keeps the endpoint reachable).
if net_rebuild_pending {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
let (stack, live) = replace_net_stack(
net,
"Applying deferred network settings",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
network_mode = live;
net_rebuild_pending = false;
}
}
@@ -2750,17 +2862,21 @@ async fn run_core_loop(
// idle; if a call is active, defer to the next Leave/Join so the
// live call isn't disrupted (preserves "applies on next join").
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
let (stack, live) = replace_net_stack(
net,
"Network mode change",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
network_mode = live;
} else {
net_rebuild_pending = true;
}
@@ -2798,17 +2914,22 @@ async fn run_core_loop(
// key unchanged, so a rebuild would be pointless churn).
if regenerated {
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
// Same mode both attempts — the fallback is a plain
// retry under the (already persisted) new key.
let (stack, live) = replace_net_stack(
net,
"Endpoint restart after identity change",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
} else {
net_rebuild_pending = true;
}
@@ -3322,10 +3443,10 @@ fn replace_viewer_index<T>(viewers: &[(String, T)], ticket: &str) -> Option<usiz
mod tests {
use super::{
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained, apply_peer_volume,
apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop, frame_level,
mix_frames, mix_stereo_frames, next_game_change, replace_viewer_index, send_playback_frame,
should_auto_fetch, stereo_to_mono,
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop,
frame_level, mix_frames, mix_stereo_frames, next_game_change, rebuild_with_fallback,
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono,
};
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
use std::collections::{HashMap, HashSet};
@@ -3349,6 +3470,101 @@ mod tests {
assert_eq!(replace_viewer_index::<u8>(&[], "ticket-A"), None);
}
// --- rebuild_with_fallback: the retry policy behind replace_net_stack ---
// The builder is injected, so these cover the policy without sockets. The
// closure does its bookkeeping synchronously and returns a ready future.
#[tokio::test]
async fn rebuild_keeps_requested_posture_on_first_success() {
let calls = std::cell::RefCell::new(Vec::new());
let out = rebuild_with_fallback(
|mode| {
calls.borrow_mut().push(mode);
std::future::ready(Ok::<u8, String>(7))
},
NetworkMode::DirectOnly,
NetworkMode::N0Full,
)
.await;
assert_eq!(out, Ok((7, NetworkMode::DirectOnly, None)));
// No second build: the live posture is only a fallback.
assert_eq!(*calls.borrow(), vec![NetworkMode::DirectOnly]);
}
#[tokio::test]
async fn rebuild_falls_back_to_the_live_posture_when_the_requested_one_fails() {
let calls = std::cell::RefCell::new(Vec::new());
let out = rebuild_with_fallback(
|mode| {
calls.borrow_mut().push(mode);
std::future::ready(if mode == NetworkMode::DirectOnly {
Err("bind failed".to_string())
} else {
Ok(7u8)
})
},
NetworkMode::DirectOnly,
NetworkMode::N0Full,
)
.await;
// A stack is up on the OLD posture and the caller learns both that it
// fell back (mode) and why (the primary error) — no silent zombie.
assert_eq!(
out,
Ok((7, NetworkMode::N0Full, Some("bind failed".to_string())))
);
assert_eq!(
*calls.borrow(),
vec![NetworkMode::DirectOnly, NetworkMode::N0Full]
);
}
#[tokio::test]
async fn rebuild_reports_both_errors_when_networking_is_gone() {
let out = rebuild_with_fallback(
|_| std::future::ready(Err::<u8, String>("bind failed".to_string())),
NetworkMode::DirectOnly,
NetworkMode::N0Full,
)
.await;
assert_eq!(
out,
Err(("bind failed".to_string(), "bind failed".to_string()))
);
}
#[tokio::test]
async fn rebuild_with_equal_postures_is_a_plain_retry() {
// RegenerateIdentity rebuilds under the same mode: the fallback is a
// second attempt with identical parameters, not a posture change.
let calls = std::cell::Cell::new(0u8);
let out = rebuild_with_fallback(
|mode| {
calls.set(calls.get() + 1);
assert_eq!(mode, NetworkMode::RelayNoDiscovery);
std::future::ready(if calls.get() == 1 {
Err("transient".to_string())
} else {
Ok(7u8)
})
},
NetworkMode::RelayNoDiscovery,
NetworkMode::RelayNoDiscovery,
)
.await;
// Succeeded on the requested posture, so the caller treats the change
// as applied (the Some(err) is logged, not surfaced as a UI error).
assert_eq!(
out,
Ok((
7,
NetworkMode::RelayNoDiscovery,
Some("transient".to_string())
))
);
assert_eq!(calls.get(), 2);
}
#[test]
fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained.
+5 -1
View File
@@ -374,7 +374,11 @@ pub async fn spawn_host(
// Log the exact argv we hand pixelpass so a field log can confirm which
// encode/quality flags (e.g. --bitrate) actually reached the host — these
// are local flags with no ticket/secret, so logging them verbatim is safe.
crate::log_msg(&format!("pixelpass host spawn: {} {}", bin.display(), args.join(" ")));
crate::log_msg(&format!(
"pixelpass host spawn: {} {}",
bin.display(),
args.join(" ")
));
let mut child = Command::new(bin)
.args(&args)
.stdin(Stdio::null())
+5 -1
View File
@@ -766,7 +766,11 @@ where
}
}
MenuAction::Paste => {
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Standard).unwrap_or_default());
let clip = sanitize_clip(
&clipboard
.read(clipboard::Kind::Standard)
.unwrap_or_default(),
);
let edit = paste(self.value, start, end, &clip);
self.publish_paste(edit, shell);
+3 -1
View File
@@ -304,7 +304,9 @@ async fn connection_stats_report_a_direct_path_with_live_counters() {
assert_eq!(info.remote_addr, s2.remote_addr);
assert!(info.rtt_ms < 1000, "localhost RTT should be sane");
assert!(
info.up_kbps.expect("same path + positive window has a rate") > 0.0,
info.up_kbps
.expect("same path + positive window has a rate")
> 0.0,
"audio was flowing, so the upstream rate must be non-zero"
);
}