Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6eb490939 | ||
|
|
e724167b03 | ||
|
|
7b9cb57003 | ||
|
|
af7a42a049 | ||
|
|
e78e7bc2a5 | ||
|
|
52ab374b74 | ||
|
|
8825707c17 |
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
@@ -36,7 +36,9 @@ jobs:
|
|||||||
run: cargo test --doc
|
run: cargo test --doc
|
||||||
|
|
||||||
- name: cargo-deny (advisories, bans, licenses, sources)
|
- 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
|
- name: cargo-audit
|
||||||
run: cargo audit
|
run: cargo audit
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ name: windows-build
|
|||||||
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
# 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
|
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
||||||
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
|
# `windows-latest` label (a Linux-container approach does NOT apply here —
|
||||||
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
|
# Windows jobs run on the host, not a Linux container). If your runner
|
||||||
# runner advertises a different label, change `runs-on` below. Until a Windows
|
# advertises a different label, change `runs-on` below.
|
||||||
# runner exists this workflow is simply skipped/queued, not a failure of the
|
#
|
||||||
# Linux CI.
|
# 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
|
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
||||||
# peerspeak-windows-opus-spike.md):
|
# peerspeak-windows-opus-spike.md):
|
||||||
@@ -23,12 +32,7 @@ name: windows-build
|
|||||||
# must provide both.
|
# must provide both.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Manual runs from the Gitea Actions UI only — see the header comment.
|
||||||
# `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.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1207,9 +1207,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ ignore = [
|
|||||||
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
# 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.
|
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
||||||
"RUSTSEC-2026-0150",
|
"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",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -6239,7 +6239,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
// Rendered only while the audio link is live — the
|
// Rendered only while the audio link is live — the
|
||||||
// Connecting/Reconnecting indicator covers the rest.
|
// Connecting/Reconnecting indicator covers the rest.
|
||||||
if let Some(info) = state.conn_stats.get(peer_id) {
|
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![
|
let badge = row![
|
||||||
text("●").size(9).color(dot_color),
|
text("●").size(9).color(dot_color),
|
||||||
text(conn_badge_label(info)).size(11).color(color_subtext),
|
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);
|
.align_y(iced::alignment::Vertical::Center);
|
||||||
let detail = column![
|
let detail = column![
|
||||||
text(conn_tooltip_path(info)).size(11).color(color_text),
|
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!(
|
text(format!(
|
||||||
"↑ {} ↓ {}",
|
"↑ {} ↓ {}",
|
||||||
conn_rate_label(info.up_kbps),
|
conn_rate_label(info.up_kbps),
|
||||||
@@ -6261,9 +6267,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
name_col = name_col.push(
|
name_col = name_col.push(
|
||||||
tooltip(
|
tooltip(
|
||||||
badge,
|
badge,
|
||||||
container(detail)
|
container(detail).padding(8).style(c_style(
|
||||||
.padding(8)
|
color_crust,
|
||||||
.style(c_style(color_crust, color_surface, 6.0)),
|
color_surface,
|
||||||
|
6.0,
|
||||||
|
)),
|
||||||
iced::widget::tooltip::Position::Bottom,
|
iced::widget::tooltip::Position::Bottom,
|
||||||
)
|
)
|
||||||
.gap(6),
|
.gap(6),
|
||||||
|
|||||||
+88
-3
@@ -202,15 +202,20 @@ impl JitterBuffer {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Gap with later packets already buffered: a packet was lost
|
// Gap with later packets already buffered: a packet was lost
|
||||||
// or reordered out of window. First try Opus in-band FEC from
|
// or reordered out of window. Try Opus in-band FEC from the
|
||||||
// the next packet; if unavailable, fall back to plain PLC.
|
// 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.next_seq = Some(next.wrapping_add(1));
|
||||||
self.note_disruption();
|
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
|
self.decoder
|
||||||
.decode_fec(next_payload)
|
.decode_fec(next_payload)
|
||||||
.or_else(|_| self.decoder.decode(None))
|
.or_else(|_| self.decoder.decode(None))
|
||||||
.ok()
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn drops_packets_already_played() {
|
fn drops_packets_already_played() {
|
||||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
|||||||
+244
-28
@@ -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.
|
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
|
||||||
///
|
///
|
||||||
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
|
/// 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
|
// 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.
|
// "applies on next join" semantics while keeping the endpoint up while idle.
|
||||||
let mut net_rebuild_pending = false;
|
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).
|
// 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),
|
// `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
|
// active, rebuild the persistent stack now — after the old session is
|
||||||
// gone, before the new one binds — so this join uses the new posture.
|
// gone, before the new one binds — so this join uses the new posture.
|
||||||
if net_rebuild_pending {
|
if net_rebuild_pending {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
let (stack, live) = replace_net_stack(
|
||||||
secret_key.clone(),
|
net,
|
||||||
|
"Applying deferred network settings",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.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;
|
net_rebuild_pending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2594,17 +2702,21 @@ async fn run_core_loop(
|
|||||||
// Apply any network-mode / identity change that was deferred while we
|
// Apply any network-mode / identity change that was deferred while we
|
||||||
// were in the call (rebuild while idle keeps the endpoint reachable).
|
// were in the call (rebuild while idle keeps the endpoint reachable).
|
||||||
if net_rebuild_pending {
|
if net_rebuild_pending {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
let (stack, live) = replace_net_stack(
|
||||||
secret_key.clone(),
|
net,
|
||||||
|
"Applying deferred network settings",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
|
network_mode = live;
|
||||||
net_rebuild_pending = false;
|
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
|
// idle; if a call is active, defer to the next Leave/Join so the
|
||||||
// live call isn't disrupted (preserves "applies on next join").
|
// live call isn't disrupted (preserves "applies on next join").
|
||||||
if active_session.is_none() {
|
if active_session.is_none() {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
let (stack, live) = replace_net_stack(
|
||||||
secret_key.clone(),
|
net,
|
||||||
|
"Network mode change",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
|
network_mode = live;
|
||||||
} else {
|
} else {
|
||||||
net_rebuild_pending = true;
|
net_rebuild_pending = true;
|
||||||
}
|
}
|
||||||
@@ -2798,17 +2914,22 @@ async fn run_core_loop(
|
|||||||
// key unchanged, so a rebuild would be pointless churn).
|
// key unchanged, so a rebuild would be pointless churn).
|
||||||
if regenerated {
|
if regenerated {
|
||||||
if active_session.is_none() {
|
if active_session.is_none() {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
// Same mode both attempts — the fallback is a plain
|
||||||
secret_key.clone(),
|
// retry under the (already persisted) new key.
|
||||||
|
let (stack, live) = replace_net_stack(
|
||||||
|
net,
|
||||||
|
"Endpoint restart after identity change",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
} else {
|
} else {
|
||||||
net_rebuild_pending = true;
|
net_rebuild_pending = true;
|
||||||
}
|
}
|
||||||
@@ -3322,10 +3443,10 @@ fn replace_viewer_index<T>(viewers: &[(String, T)], ticket: &str) -> Option<usiz
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
|
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
|
||||||
PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained, apply_peer_volume,
|
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
|
||||||
apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop, frame_level,
|
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop,
|
||||||
mix_frames, mix_stereo_frames, next_game_change, replace_viewer_index, send_playback_frame,
|
frame_level, mix_frames, mix_stereo_frames, next_game_change, rebuild_with_fallback,
|
||||||
should_auto_fetch, stereo_to_mono,
|
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono,
|
||||||
};
|
};
|
||||||
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
|
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -3349,6 +3470,101 @@ mod tests {
|
|||||||
assert_eq!(replace_viewer_index::<u8>(&[], "ticket-A"), None);
|
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]
|
#[test]
|
||||||
fn admit_retained_rejects_only_new_ids_at_the_cap() {
|
fn admit_retained_rejects_only_new_ids_at_the_cap() {
|
||||||
// Below the cap, a brand-new identity is retained.
|
// Below the cap, a brand-new identity is retained.
|
||||||
|
|||||||
@@ -374,7 +374,11 @@ pub async fn spawn_host(
|
|||||||
// Log the exact argv we hand pixelpass so a field log can confirm which
|
// 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
|
// encode/quality flags (e.g. --bitrate) actually reached the host — these
|
||||||
// are local flags with no ticket/secret, so logging them verbatim is safe.
|
// 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)
|
let mut child = Command::new(bin)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
|
|||||||
@@ -766,7 +766,11 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Paste => {
|
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);
|
let edit = paste(self.value, start, end, &clip);
|
||||||
|
|
||||||
self.publish_paste(edit, shell);
|
self.publish_paste(edit, shell);
|
||||||
|
|||||||
@@ -304,7 +304,9 @@ async fn connection_stats_report_a_direct_path_with_live_counters() {
|
|||||||
assert_eq!(info.remote_addr, s2.remote_addr);
|
assert_eq!(info.remote_addr, s2.remote_addr);
|
||||||
assert!(info.rtt_ms < 1000, "localhost RTT should be sane");
|
assert!(info.rtt_ms < 1000, "localhost RTT should be sane");
|
||||||
assert!(
|
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"
|
"audio was flowing, so the upstream rate must be non-zero"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user