Author SHA1 Message Date
molluskandClaude Opus 4.8 0588d92537 release: 0.6.6
CI / check (push) Failing after 5m35s
The live-edge catch-up (8c4f4a0, b4a4c00) landed after the v0.6.5 tag, so
the 0.6.5 artifacts do not contain it — the same gap that left the fix out
of v0.6.4. Cut 0.6.6 so the published build actually carries it.

Local-only changes (no wire change; PROTO planes unchanged), so this is a
PATCH bump per VERSIONING.md.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:57:05 -04:00
molluskandClaude Opus 4.8 b4a4c00711 fix(screenshare): make live-edge catch-up actually recover
CI / check (push) Failing after 2m37s
The first cut used a fixed 1.05x drain, which measurement showed was too
gentle to matter: clearing a 6 s backlog would take two minutes, which a
viewer experiences as still broken.

Two changes, both measured on the netem satellite rig (loopback
impairment, gst -> ffmpeg HTTP relay -> mpv, matching the http:// URL
production actually serves):

1. Proportional drain. Speed now scales with buffer depth,
   1 + 0.05*(cache - 0.5), clamped to 1.15x, keeping the hysteresis band
   so it cannot oscillate. Deep backlogs recover in tens of seconds;
   small excursions still get an inaudible nudge.

2. Bound the byte cache in Low latency. The demuxer cache is a *byte*
   budget, so at a given bitrate it sets the worst-case backlog: 2 MiB
   held ~6 s of a 2.5 Mbps share. Capping Low latency at 1 MiB halved the
   standing buffer, 6.0 s -> 2.8 s, on its own. Smooth keeps the user's
   value, since a deep buffer is that posture's whole point.

Measured effect with both: playback consumes 11.6% faster than realtime
while behind (ratio 1.1157 vs 0.9988 with catch-up off), i.e. ~9 s of
backlog cleared in 80 s where before it recovered nothing at all and the
viewer stayed behind for the rest of the call.

Rig caveat: its upstream queues hold an unbounded backlog, so the cache
never drops back through the low mark and the return-to-1x transition is
only covered by unit tests, not the rig.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:47:32 -04:00
molluskandClaude Opus 4.8 8c4f4a0b8b feat(screenshare): drain a lagging viewer back to the live edge
CI / check (push) Failing after 2m12s
On a lossy link the reliable PixelPass transport turns every loss burst
into buffered latency that nothing trims back, so the viewer settles
seconds behind the host and stays there. Measured on a tc netem satellite
simulation: a viewer parks at a ~6 s standing buffer indefinitely.

--untimed (0.6.5) does NOT fix this and measured marginally worse (+1.38 s
vs +1.24 s): it only unpaces presentation, while audio still drains at 1x
the DAC rate, so an accumulated backlog never shrinks. Drop it.

Instead give mpv a JSON IPC socket in the Low latency posture and drive
playback slightly fast while the buffer is deep, returning to 1x once it
drains. Pitch correction keeps it inaudible and A/V sync is preserved,
because audio and video speed up together.

The control law and IPC message handling are pure functions with unit
tests; the only I/O is livesync::drive, which ends by itself when the
player exits. Smooth is deliberately excluded — its ~2 s readahead is the
point of that posture, and catch-up would fight it every poll.

Known limitation: 1.05x needs ~120 s to clear a 6 s backlog, so recovery
is slower than ideal. Tuning (a proportional law, or a seek-to-live for
large backlogs) is the follow-up.

598 lib tests green (+11), clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:36:06 -04:00
molluskandClaude Opus 4.8 76c4f68bb3 release: 0.6.5
CI / check (push) Successful in 2m54s
Local-only changes since 0.6.4 (no wire change; PROTO planes unchanged),
so this is a PATCH bump per VERSIONING.md.

Ships the low-latency screen-share live-edge fix (4bfc184), which landed
three hours after the v0.6.4 tag and was therefore never released.

Also adds the missing CHANGELOG entry for the participant "Advanced audio"
foldout (26d6600), which shipped without one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:45:16 -04:00
mollusk c427231858 feat(notifications): add chat and contact sounds
CI / check (push) Successful in 2m39s
2026-07-19 02:02:03 -04:00
mollusk 4bfc18463b fix(screenshare): keep low-latency playback live 2026-07-18 22:22:24 -04:00
mollusk 26d66007de ui: fold participant audio controls 2026-07-18 20:14:09 -04:00
molluskandClaude Fable 5 3d7b01c8a2 release: 0.6.4
CI / check (push) Failing after 3m17s
Wire-compatible refinement release (GOSSIP_PROTO stays 5). Highlights:
honest chat send status + sender-side pacing (chat-hardening Phase 5),
completing the chat-hardening plan; playlist drawer de-clutter + auto-resize;
plus the FEC-gap and network-restart fixes already on the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:16:39 -04:00
molluskandClaude Fable 5 5f4eba1815 chat: honest local send status + sender-side pacing (Phase 5)
CI / check (push) Successful in 4m17s
Closes the final phase of docs/chat-hardening-plan.md. Two problems: a
locally echoed message always looked sent even when the core had no active
session or the gossip broadcast failed; and a fast burst could broadcast
'successfully' yet be silently dropped by every receiver's per-author rate
bucket (8 burst, then 1/s) with no sender feedback.

Send status: CoreCommand::SendChat/SendChatFile carry a local-only id (never
on the wire); the core replies with UiEvent::ChatSendResult after the gossip
broadcast succeeds or fails, and a no-active-session is now an explicit
failure rather than a silent no-op. gossip send_chat, which previously
returned Ok on a missing sender/topic or an encode failure, now returns Err.
ChatEntry gains local_send: Option<LocalSend>; failed sends render a red
'Not sent — {reason}  [Retry]' line, Broadcast/Pending render nothing
(there are no delivery receipts, so silence is the honest success state).

Sender-side pacing (new src/app/sendqueue.rs): sends past the burst queue
locally as 'queued…' and trickle out at the receivers' sustained rate, so
nothing is lost and typing is never blocked (user chose queue-and-trickle
over input throttling). The pacer reuses the gossip gate's own TokenBucket +
per-author constants (now pub(crate)) so the two sides of the policy can't
drift. A 250ms drain subscription runs only while the queue is non-empty.
Retry re-dispatches the retained payload; re-serving the same attachment id
replaces the ServeStore entry rather than double-counting bytes. The pacer
and monotonic send-id counter survive a room reset (receivers' buckets
persist; ids never alias a late result); queue and retry payloads are cleared.

582 lib tests (+11: 4 pacer/queue seam, 7 app-level transition/retry/reset);
all-targets green, clippy -D warnings clean, fmt clean, smoke launch OK. No
wire change (GOSSIP_PROTO stays 5). Tests-green-only — the two owed
two-machine field-test items are logged in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:25 -04:00
molluskandClaude Fable 5 77d2bf2992 style(music): de-duplicate drawer transport controls, let playlist fill drawer height
CI / check (push) Successful in 2m43s
The playlist drawer duplicated the player bar's |prev/play/next| transport
row even though the drawer can only be open while the bar is visible
(drawer_open gates on show_player_bar), so the drawer copy is removed;
seek, music volume, Browse, and the tune-in checkbox remain drawer-only.

The track list (and the Public tab's broadcast list) was a 160px-fixed
scrollable nested inside a second full-height scrollable, showing only a
few entries. The outer scrollable is gone and both lists now fill the
drawer's remaining height, resizing with the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:25:34 -04:00
molluskandClaude Fable 5 c8d0053431 docs(plan): add Phase 4 items to the two-machine field-test checklist
CI / check (push) Successful in 3m31s
Also re-triggers CI: run 165 on 1d038be died to rust-lld crashes from disk
exhaustion on the runner host (12G free vs ~12G cold-build transient), not a
code failure; 18G of local build artifacts have been swept (30G free now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:04:25 -04:00
molluskandClaude Fable 5 1d038be9a0 chat: parsed-URL link policy, cached link ranges, history byte budget (Phase 4)
CI / check (push) Failing after 3m10s
Phase 4 of docs/chat-hardening-plan.md — URL and rendering resilience.
Closes the chat-body half of S14 (bidi override strip).

- sanitize: new is_safe_web_url shared link policy (url crate, promoted to a
  direct dependency): http/https scheme + non-empty host + no userinfo;
  candidates failing it stay plain text (their whole whitespace run, interior
  not re-scanned). Scheme detection is now ASCII-case-insensitive.
- sanitize: linkify() -> link_ranges()/segments(): validated byte ranges
  computed once, exact-roundtrip slicing, at most CHAT_MSG_MAX_LINKS (8)
  clickable links per message; the rest stays selectable plain text.
- sanitize_chat: strips bidi overrides/isolates (U+202A-202E, U+2066-2069)
  from message bodies while keeping ZWJ/ZWNJ/LRM/RLM (S14 chat-body half).
- app: ChatEntry caches its link ranges (filled in push_chat), so redraws
  slice instead of rescanning/re-validating; only link spans allocate.
- app: chat history now also bounded by 512 KiB total sanitized text
  (CHAT_HISTORY_MAX_TEXT_BYTES) alongside the 300-entry cap; the attachment
  byte cache is deliberately untouched by history eviction (own budgets).
- app: AppMessage::OpenUrl re-checks the same parsed policy (defence in
  depth) instead of prefix checks - non-web schemes can never reach the
  opener even if the handler is invoked directly.

571 lib tests green (+3 net); clippy -D warnings + fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:57:15 -04:00
molluskandClaude Fable 5 554b613466 chat: attachment cache, download, and transfer hardening (Phase 3)
CI / check (push) Successful in 2m33s
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn
into unbounded memory, bandwidth, decoder, or task pressure (S15 closed;
S14's filename half closed).

Cache and image cost (3A): AttachmentCache now carries encoded- and
decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with
per-entry weights, replacement accounting, and oldest-first eviction; an
individually over-budget fetch services any pending Save/Play from the
bytes in hand and is exposed as Evicted instead of retained.
validate_image_bytes prechecks header dimensions (per-side AND a new
14 MP total-pixel limit) before any decode; the renderer only ever
receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts
against the decoded budget — originals stay encoded-only for Save.
sanitize_filename strips the bidi/zero-width spoofing set (RTL-override
extension spoof).

Download policy and state (3B): images auto-fetch only when roster-
authored AND declared ≤4 MiB, gated by a new deterministic
AutoFetchBudget (per-author and session request+byte token buckets,
check-then-take, bounded author map) alongside the existing dedup and
four-permit bound. Attachment state is now explicit — absence/Loading/
Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so
skipped or evicted images render a "Load image" button instead of an
indefinite "loading…", and repeated clicks can never spawn duplicate
fetch tasks.

Exact transfers and serve store (3C): fetch_blob requires the received
length to equal the declared size (short = local error, overlong =
bounded-read reject, empty keeps meaning "sender no longer has it");
the file picker's unbounded read is replaced by a metadata-prechecked
cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command
queue, and serve store; served_files is a count- and byte-budgeted FIFO
ServeStore (16 entries / 128 MiB).

37 new tests (568 lib total) including a real two-endpoint loopback
exercising exact/short/overlong/unknown-id transfers. Plan checkboxes
ticked and constant deviations decision-logged. Tests-green-only: the
plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:09:30 -04:00
molluskandClaude Fable 5 8898652349 chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
CI / check (push) Successful in 2m59s
Chat-hardening plan Phase 2 — only current authenticated room members can
create chat UI work, impersonation via the wire name is structurally closed,
and no member can monopolize the event channel:

- core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the
  event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on
  graceful PeerLeft AND terminal grace-expiry eviction (both timer paths).
  Non-roster chat is dropped before attachment handling; the rendered author
  label is the roster-bound name — the sender-claimed wire name is never read.
- gossip: ChatIngressGate after verify_gossip, before any sanitize work or
  event send: early known-author gate (live + mid-reconnect peers), exact-
  replay suppression keyed on the deterministic Ed25519 signature (1024-entry
  cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then
  per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets.
  Replays are detected before tokens are consumed; a room-bucket reject
  refunds the author token; rejection logging is squelched per author.
- The inner Chat.ts is now ignored entirely; RoomEvent carries the signed
  envelope timestamp.

550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority,
terminal eviction revokes it), clippy --all-targets -D warnings clean.
Tests-green-only: the plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:37:13 -04:00
molluskandClaude Fable 5 5927148ee4 chat: enforce shared text policy at UI, sign point, and gossip ingress
CI / check (push) Successful in 3m4s
Chat-hardening plan Phase 1. The chat body policy (2,000-char + 8 KiB
ceilings, single-pass control/whitespace normalization) moves from the UI
layer into src/sanitize.rs and is now enforced at every trust boundary:
cap_chat_input bounds the live input (oversized paste), the gossip sign
point re-sanitizes so non-UI callers can't bypass policy, and gossip
ingress rejects oversized raw text before sanitizing (admit_chat_text)
and drops messages with neither visible text nor an attachment. The
incoming chat author label now uses the strict name sanitizer until
Phase 2 roster-binds it. +8 tests (532 lib green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:36:21 -04:00
molluskandClaude Fable 5 93f4954653 docs: changelog for the jitter FEC and net-rebuild resilience fixes
CI / check (push) Successful in 2m26s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:42:08 -04:00
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
35 changed files with 5188 additions and 586 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:
+90
View File
@@ -2,6 +2,96 @@
All notable changes to PeerSpeak are documented here.
## [Unreleased]
## [0.6.6] — 2026-07-19
### Fixed
- **A screen share that falls behind now catches back up.** On a lossy
connection (satellite links are the worst case) the share could settle several
seconds behind the host and simply stay there for the rest of the call. The
viewer now notices a deep buffer and plays imperceptibly fast until it is back
at the live edge — the audio stays in tune and in sync while it does. This
replaces the previous attempt at the problem, which measurement showed did not
help. Applies to the Low latency setting; Smooth intentionally keeps its
larger buffer.
### Changed
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
setting is a size in megabytes, which at a given bitrate quietly decides how
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
regardless of the setting, which halved how far behind a share fell on a bad
connection before anything else kicked in. Smooth still honors the value you
choose, since a deep buffer is the point of that mode.
## [0.6.5] — 2026-07-19
### Added
- **Chat message sounds.** Successful outgoing messages and admitted incoming
messages now have distinct notification chimes, each with its own enable
toggle and optional custom WAV path in Notifications settings.
- **Contact presence sounds.** The home-screen contacts list now announces a
contact becoming online or offline. Initial online contacts are announced;
initial offline results stay silent. Both events have independent toggles and
optional custom WAV paths.
- **Notification sound browser.** Every notification event now has a native
Browse button for choosing a custom WAV instead of typing its path manually.
### Changed
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
for each participant now live behind an **"Advanced audio"** foldout instead
of being expanded all the time, so a call with several people no longer fills
the panel with sliders. The controls themselves are unchanged.
### Fixed
- **Low-latency screen sharing stays near the live edge again.** mpv's
timestamp pacing could let stale frames accumulate across the reliable
PixelPass transport until a share was 710 seconds behind. Low-latency mode
now presents decoded frames immediately; Smooth mode retains timestamp pacing
when keeping shared-video audio and video synchronized matters more.
## [0.6.4] — 2026-07-18
### Added
- **Chat now tells you when a message didn't send.** A message that couldn't go
out — because you weren't in a room, or the broadcast failed — is marked
**"⚠ Not sent"** with a **Retry** button, instead of sitting in the transcript
looking delivered. A successful send shows nothing (PeerSpeak has no
delivery/read receipts, so anything else would be a false promise).
- **Fast typing no longer loses messages.** When you fire off a quick burst,
messages past the first few are held as **"queued…"** and sent a moment apart,
matching the rate other people's clients accept. Previously a fast burst could
look sent on your end while some messages silently never reached the room.
### Changed
- **Tidier music playlist drawer.** The slide-out playlist no longer repeats the
play/skip controls already on the player bar, and the track list now grows to
fill the drawer instead of being boxed into a short scroll area, so you can see
more of your playlist at once.
- **Safer chat under the hood.** A round of chat hardening tightened how incoming
messages, display names, links, and file/image attachments are validated and
bounded, so a malformed or hostile message from a peer can't spoof a name,
replay, flood, or run the app out of memory. No change to how normal chat looks
or works.
### Fixed
- **Burst packet loss no longer splices the wrong audio into the gap.** Loss
concealment used Opus in-band FEC even when the next packet to arrive wasn't
the one immediately after the gap, so losing several packets in a row could
briefly play a later frame's audio in the wrong position. FEC now only
reconstructs a gap from its immediate successor packet; larger gaps are
concealed normally.
- **A failed network restart no longer silently kills the app.** Changing the
network mode (or regenerating your identity) rebuilds the connection stack;
if that rebuild failed — rare, but possible when the local socket can't
bind — PeerSpeak kept its window open but silently stopped responding to
every command. It now falls back to your previous network settings and says
so, and only gives up (with a clear error telling you to restart) if even
the fallback fails.
[0.6.4]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.4
## [0.6.3] — 2026-07-06
### Added
Generated
+4 -3
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",
]
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.3"
version = "0.6.6"
dependencies = [
"anyhow",
"async-trait",
@@ -4894,6 +4894,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"url",
"windows-sys 0.61.2",
]
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.3"
version = "0.6.6"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
@@ -75,6 +75,10 @@ serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18"
# Chat link policy: parse + validate clickable URL candidates (scheme/host/
# userinfo checks in `sanitize::is_safe_web_url`). Already in the tree
# transitively via iroh — this only promotes it to a direct dependency.
url = "2.5"
# --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -76,6 +76,14 @@ CHIMES = {
"mic-toggle.wav": [(E5, 0.08)],
# Reconnect gave up: disappointing low two-note fall.
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
# Our chat message entered the room: a tiny bright acknowledgement.
"chat-sent.wav": [(1046.50, 0.06)],
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
# A saved contact came online: a light, higher two-note arrival.
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
# A saved contact went offline: the same tonal family falling away.
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
}
+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",
]
# ---------------------------------------------------------------------------
+584
View File
@@ -0,0 +1,584 @@
# Chat hardening — ephemeral implementation plan
**Status (2026-07-18):** Phases 15 COMPLETE (all plan phases done). Phase 1 =
shared text policy in `src/sanitize.rs`, ceilings enforced at UI input, sign
point, and gossip ingress. Phase 2 = roster-bound authorship
(`src/core/chatroster.rs`), replay dedup + rate limits (`ChatIngressGate` in
`src/network/gossip.rs`). Phase 3 = attachment cache/serve-store budgets,
downscaled previews, auto-fetch byte/request budgets (`src/core/fetchbudget.rs`),
exact transfers, bounded local reads. Phase 4 = parsed-URL link policy
(`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`, `url` crate), 8-link cap,
cached link ranges in `ChatEntry`, 512 KiB history text budget, chat-body
bidi-override strip (closes S14). Phase 5 = honest local send status
(`CoreCommand::SendChat`/`SendChatFile` carry a local id, `UiEvent::ChatSendResult`,
`SendStatus` on own echoes) PLUS sender-side pacing (`src/app/sendqueue.rs`
mirrors the receivers' per-author budget so fast bursts trickle instead of being
silently dropped downstream). All gates green each phase. This is a temporary
scope contract for hardening the existing room chat; with every phase complete
and the two-machine field test done, delete this file (see the completion note
at the end). The two-machine field-test section below is still owed before that
deletion. 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.
- [x] 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).
- [x] 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.
- [x] 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.
- [x] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
clipboard, primary-selection, and context-menu paste paths through the controlled
input widget.
- [x] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
- [x] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
caller cannot bypass policy.
- [x] At gossip ingress, reject raw chat text over the byte ceiling before doing
downstream sanitization; sanitize accepted text before creating `RoomEvent`.
- [x] Keep attachment-only messages when the sanitized caption is empty; drop a
chat with neither visible text nor a valid attachment.
- [x] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
replaces it with the roster-bound name.
### Phase 1 tests
- [x] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
- [x] Exact character and byte boundaries, including a four-byte scalar at the
cutoff.
- [x] Oversized paste never makes `state.chat_input` exceed either ceiling.
- [x] Outgoing, incoming, and direct core/network paths converge on the same
normalized result.
- [x] 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.
- [x] 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).
- [x] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
reconnect grace, and remove it on graceful or terminal eviction.
- [x] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
whose author is not present in that authoritative roster.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
a replay cannot starve a legitimate new message from that author.
- [x] 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
- [x] Valid roster author is admitted; never-announced, post-leave, forged, and
stale authors are rejected.
- [x] A peer sending `name = "Victim"` renders under its own roster name.
- [x] A name update affects future messages without rewriting history.
- [x] Reconnect grace continues accepting the known author; terminal eviction does
not.
- [x] The same signed chat is displayed once; distinct chats created in the same
millisecond are both admitted.
- [x] Replay-cache TTL/cap pruning cannot grow without bound.
- [x] Per-author burst/refill and room-wide burst/refill boundaries.
- [x] 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
- [x] Extend `AttachmentCache` with encoded-byte and decoded-preview-byte counters.
Preserve the count cap, but evict oldest entries until all three budgets fit.
- [x] Give every entry an explicit weight. Replacement must subtract the old
weight before checking/inserting the new one.
- [x] 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.
- [x] Add a total-pixel limit to `validate_image_bytes` in addition to the existing
width/height limit.
- [x] 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.
- [x] Count estimated RGBA preview cost (`width * height * 4`) against the decoded
budget even if iced internally copies or uploads it.
- [x] 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
- [x] 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.
- [x] Add per-author and session byte/request budgets for automatic fetches so a
peer cannot drain bandwidth sequentially after each permit is released.
- [x] 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.
- [x] 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.
- [x] Ensure a repeated click cannot create duplicate unguarded fetch tasks.
- [x] Keep non-image attachments manual-only.
### 3C. Exact transfers, local reads, and served files
- [x] In `IrohTransport::fetch_blob`, require `bytes.len() as u64 == declared_size`.
Reject empty, short, and overlong transfers with a concise local error.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] Keep attachment ids keyed by author on receipt and preserve all existing
request-length, timeout, filename, and decoder checks.
### Phase 3 tests
- [x] Byte-budget eviction, count eviction, replacement accounting, clear/reset,
and an individually overweight entry.
- [x] Decoded-preview budget and downscale dimensions for wide, tall, square, and
boundary images.
- [x] Image with valid per-side dimensions but excessive total pixels is rejected.
- [x] A declared 4 MiB image auto-fetches; the first byte over the limit requires a
click.
- [x] Per-author/session auto-fetch budgets recover according to their policy and
never exceed task concurrency.
- [x] Short, exact, and overlong file responses.
- [x] Local file reader stops at cap + 1 instead of allocating the full source.
- [x] Served-file FIFO/byte eviction and replacement accounting.
- [x] 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.
- [x] Make `url` a direct dependency (already present transitively) and validate
link candidates with `url::Url`.
- [x] A clickable URL must have an `http` or `https` scheme and a valid host.
- [x] 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.
- [x] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
replace prefix checks with the shared parsed-URL policy.
- [x] Cap clickable candidates at eight per message. Remaining content stays
selectable plain text and must still round-trip exactly.
- [x] Refactor linkification to return borrowed ranges/offsets or cache link ranges
in `ChatEntry`, avoiding allocation and rescanning on every redraw.
- [x] 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.
- [x] Do not add metadata fetching, remote images, Markdown, or link previews.
- [x] (Folded in from S14, per the security handoff) Strip bidi
overrides/isolates from the chat BODY in `sanitize_chat`, keeping the other
expressive format characters (ZWJ/ZWNJ/LRM/RLM).
### Phase 4 tests
- [x] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
punctuation, credentials/userinfo, and non-web schemes.
- [x] Eight-link boundary and many-link adversarial input.
- [x] Segment/range reconstruction exactly reproduces the sanitized message.
- [x] Entry-count and total-text-budget history eviction.
- [x] 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.
- [x] 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. (`ChatEntry.local_send:
Option<LocalSend>`; `SendStatus` also has `Queued` for the paced-but-not-yet-sent
state — see the pacing decision-log entry.)
- [x] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a
`UiEvent` result after the local gossip broadcast call succeeds or fails.
(`SendChat`/`SendChatFile` gained `local_id`; new `UiEvent::ChatSendResult { local_id,
error }`.)
- [x] If the core is not in an active session, return failure instead of silently
doing nothing. (`send_chat` now `Err`s on missing sender/topic and on encode
failure; the core arm maps no-session to a `ChatSendResult` error.)
- [x] Show failure compactly with a retry action. A successful local broadcast must
not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements.
(Failed → red "⚠ Not sent — {reason} [Retry]" line; Broadcast/Pending render
nothing — silence is the honest success state.)
- [x] Retry creates one new signed broadcast while retaining replay correctness and
attachment serving state. (`RetryChatSend(id)` re-dispatches the retained
`PendingSend`; re-serving the same attachment id REPLACES the `ServeStore`
entry, never double-counts — see `serve_store_replacement_accounting_and_remove_clear`.)
### Phase 5 tests
- [x] Local echo starts pending, becomes broadcast on success, and becomes failed
on no-session/channel/gossip error. (`send_status_pending_then_broadcast_on_success`,
`send_status_failed_keeps_payload_for_retry`.)
- [x] Results update only the matching local entry, including after history
eviction or room reset. (`send_result_updates_only_the_matching_entry`,
`send_result_after_eviction_drops_orphan_payload`, `send_result_after_room_reset_is_a_noop`.)
- [x] Retry does not duplicate served bytes or mutate an unrelated entry.
(`retry_redispatches_only_the_targeted_send`; served-byte dedup =
`serve_store_replacement_accounting_and_remove_clear` in `files.rs`.)
## 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.
- [ ] A message with more than eight URLs renders eight clickable links and the
rest as selectable plain text, with nothing dropped.
- [ ] A message attempting bidi-override display spoofing renders in send order
(the override characters are stripped, emoji/joining-script text intact).
- [ ] Send a fast burst (>8 messages in a second): all arrive at the peer in
order, none silently lost; the sender sees "queued…" on the overflow that
then clears as each goes out.
- [ ] Send with no active session (or a failing broadcast): the message shows
"⚠ Not sent" with a Retry, and Retry resends it once when connectivity is back.
## 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.
- **2026-07-17 (Phase 1):** The 8 KiB byte ceiling deliberately cannot bind on
*sanitized* output (2,000 scalars × 4 bytes = 8,000 ≤ 8,192), so inside
`sanitize_chat`/`cap_chat_input` it is defense in depth; its operative role is
the raw-ingress reject in `admit_chat_text`.
- **2026-07-17 (Phase 1):** Interim until Phase 2's roster binding: the incoming
chat `name` now goes through the strict `sanitize_name` label sanitizer at the
UI edge (was the body sanitizer), so author labels already get bidi/zero-width
stripping and the 48-char label cap.
- **2026-07-17 (Phase 1):** `send_chat` at the gossip sign point silently no-ops
(Ok) on an empty-after-sanitize body with no attachment rather than erroring;
the UI already prevents this case, and Phase 5's send-status work is where
send-path feedback gets designed.
- **2026-07-17 (Phase 2):** Replay dedup is keyed on the payload's own Ed25519
**signature bytes** instead of a BLAKE3 digest (the plan allowed "an equally
collision-resistant existing primitive"): ed25519 signing is deterministic
(RFC 8032), so the 64-byte signature is already a collision-resistant
fingerprint of the exact signed bytes — same dedup power, zero new direct
dependencies. Cache entries are stamped with the signed envelope `ts` and
pruned once it exits the freshness window, because `verify_gossip` already
rejects such a frame before the cache is consulted.
- **2026-07-17 (Phase 2):** A room-bucket reject refunds the just-consumed
author token, so a room-wide squeeze caused by other members does not also
drain an innocent author's personal budget.
- **2026-07-17 (Phase 2):** Rate-limited frames are NOT entered into the replay
cache: only fully admitted chats are. A legitimate message the room was too
busy for, redelivered later by the swarm, is then displayed once instead of
being misread as a replay of something never shown.
- **2026-07-17 (Phase 2):** The "wire name never renders" guarantee is
structural: the core event task binds the wire field as `name: _` and builds
`UiEvent::ChatMessage` exclusively from `ChatRoster::name_of`, so there is no
code path from wire name to UI. The roster map behavior is unit-tested; the
end-to-end impersonation scenario stays on the (still-open) two-machine
field-test list.
- **2026-07-17 (Phase 2):** The channel-pressure requirement is met at the seam
level: chat admission is bounded (32-burst / 8-per-s room-wide) BEFORE any
`event_tx.send`, and `Announce`/`Leave` admission is independent of the chat
gate — verified by unit tests. A full gossip-loop pressure harness was not
built; the seam bound is what protects the channel.
- **2026-07-17 (Phase 2):** An empty-after-sanitize roster name falls back to
the short node id, so a member who announces an all-control-character name
still gets a stable, non-blank chat label.
- **2026-07-17 (Phase 2):** The "Consider applying the same local submit policy
to accidental rapid Enter" item is DEFERRED: the receiving side is the
security boundary (every peer independently enforces the buckets), and a
local silent drop would be a UX regression better designed alongside Phase
5's honest send status.
- **2026-07-18 (Phase 3):** Constants that deviate from the proposed table, all
bound-tested: total decoded pixels **14 MP** (not 16 MP) so the bound clears
12 MP phone photos (4032×3024) yet actually binds inside the 4096²≈16.8 MP
per-side envelope; cache encoded budget **96 MiB** (not 128) — still several
full-size files, tighter worst case; serve store **128 MiB + 16 entries**
(not 256 MiB) — a sender's own session should not pin a quarter GiB.
- **2026-07-18 (Phase 3):** `validate_image_bytes`/`decode_preview` precheck
dimensions from the container HEADER (`into_dimensions`) before any pixel
decode, so an over-limit decode bomb is rejected without paying its decode
cost; the decode-time `image::Limits` remain as defense in depth, and the
decoded dimensions must equal the prechecked header dimensions.
- **2026-07-18 (Phase 3):** Budget-pressure evictions leave NO cache entry
(absence = NotFetched → the same Load/Download affordance), while the
explicit `Evicted` state marks only an *individually over-budget* fetch whose
bytes were used once (pending Save/Play serviced from hand) and dropped. Both
render load-on-demand; only the bookkeeping differs.
- **2026-07-18 (Phase 3):** The core still runs `validate_image_bytes` before
emitting `AttachmentReady`, and the UI decodes once more to build the ≤1600px
preview. Two bounded decodes per image were accepted over shipping decoded
RGBA across the channel (which would defeat the encoded-only Arc sharing).
- **2026-07-18 (Phase 3):** The image lightbox now enlarges the ≤1600px preview
handle, not the original bitmap — originals are retained encoded-only for
Save. At the lightbox's window-sized draw area the visual difference is nil
for the chat use case; full fidelity remains one Save away.
- **2026-07-18 (Phase 3):** `AutoFetchBudget` checks all four buckets
(author/session × requests/bytes) and only then consumes atomically, so a
rejection burns nothing (no refund path like Phase 2's room bucket needed).
Tokens ARE consumed if the four-permit semaphore then rejects the spawn —
that only happens mid-flood, when charging the author is the intent.
- **2026-07-18 (Phase 3):** The auto-fetch budget's author map prunes
least-recently-active past 64 entries instead of wiring roster eviction into
the event task: authors are roster-gated upstream (≤32 live members), so
strangers cannot churn the map, and a pruned author returning with full
buckets is within policy.
- **2026-07-18 (Phase 3):** Music-track serving shares the bounded serve store
with chat attachments. A user who sends enough large attachments during a
broadcast can evict their own current track; listeners then get the standard
"sender no longer has the file" failure. Accepted: budget honesty over a
second store, and the store comfortably fits current+next track plus a
normal chat working set.
- **2026-07-18 (Phase 3):** The clip player's command channel still takes one
owned byte copy at the moment of a Play click (small, human-initiated). The
Arc de-duplication targeted the send path (UI cache / command queue / serve
store), which now shares a single allocation.
- **2026-07-18 (Phase 3):** Overlong transfers are rejected by the transport
read itself (`read_to_end(size)` errors past the bound) rather than an
explicit length compare; short transfers get the explicit
`len == declared_size` check. Music fetches ride `fetch_blob`, so they
inherit exactness for free.
- **2026-07-18 (Phase 4):** The S14 chat-body half (bidi strip) landed here per
the security handoff: `sanitize_chat` strips ONLY bidi overrides/isolates
(U+202A202E, U+20662069) — the characters that can visually reorder a
rendered line — while ZWJ/ZWNJ (emoji sequences, joining scripts) and the
LRM/RLM direction *marks* (which cannot reorder) are kept. Labels/filenames
keep the stricter full-format-strip.
- **2026-07-18 (Phase 4):** A link's href is the exact displayed slice of the
message — validation is parse-only, no normalization on open — so what the
user sees IS the argv the opener receives. Consequence: WHATWG slash
collapsing means `http:///path` parses to host `path` (as in browsers) and is
accepted; the empty-host rejects are `http://` and friends that fail parsing.
- **2026-07-18 (Phase 4):** URLs with userinfo syntax went the plan-preferred
plain-text route (no confirmation dialog). A candidate that fails the policy
leaves its WHOLE whitespace-delimited run as plain text without re-scanning
the interior — `http://a@http://b.com` yields zero links, by design.
- **2026-07-18 (Phase 4):** Scheme detection became ASCII-case-insensitive
(`Http://…` from sentence auto-capitalization now linkifies); the policy
check is unaffected since `url` normalizes scheme/host case during parsing.
- **2026-07-18 (Phase 4):** Cached ranges in `ChatEntry.links`, filled inside
`push_chat` (the single history choke point), were chosen over
borrowed-return-per-redraw: redraws now slice cached char-boundary ranges,
and only link spans allocate (their href String).
- **2026-07-18 (Phase 4):** History byte-budget eviction (512 KiB, alongside
the 300-entry cap) deliberately does NOT touch the attachment byte cache:
that cache is bounded by its own Phase 3 budgets, and leaving it alone means
an open Save/Play on an evicted line keeps its bytes-in-hand (the save
dialog falls back to the generic "download" name). The just-pushed entry is
never evicted; a single message's 8 KiB ceiling cannot exceed the budget.
- **2026-07-18 (Phase 5):** Sender-side PACING was added to Phase 5's scope
(originally receiver-status only). The Phase 2 decision log deferred the
"apply the same local submit policy to accidental rapid Enter" item to pair
with Phase 5, and honest status alone would still let a fast burst broadcast
successfully yet be silently dropped by every receiver's per-author bucket
(8 burst, then 1/s) with no sender feedback. The user chose "queue and
trickle" over "throttle input": sends past the burst queue locally as
`SendStatus::Queued` ("queued…") and release at the receivers' sustained
rate, so nothing is lost and typing is never blocked.
- **2026-07-18 (Phase 5):** The pacer (`src/app/sendqueue.rs`) reuses the
gossip gate's OWN `TokenBucket` + `CHAT_AUTHOR_BURST`/`CHAT_AUTHOR_REFILL_PER_MS`
(made `pub(crate)`), so the two sides of the rate policy are one definition
and cannot drift. It mirrors only the PER-AUTHOR budget, not the room-wide
one — we cannot know other members' send rates, and the per-author bucket is
the one guaranteed to apply to us at every receiver.
- **2026-07-18 (Phase 5):** Send status renders as a line UNDER the message
(user pick over an inline suffix glyph); `Broadcast` and the transient
`Pending` show nothing because PeerSpeak has no delivery/read receipts, so an
unadorned message IS the honest "handed to the swarm" state. Only `Queued`
and `Failed` (with Retry) are surfaced.
- **2026-07-18 (Phase 5):** The pacer and the monotonic send-id counter
deliberately SURVIVE a room reset while the queue and retry payloads are
cleared: receivers' per-author buckets persist across our rejoin (so the
pacer should not refill to full), and never-reused ids keep a late
`ChatSendResult` from a pre-reset send from aliasing a new entry — verified by
`send_result_after_room_reset_is_a_noop`.
- **2026-07-18 (Phase 5):** The pacer clock is `Instant`-based
(`AppState.send_clock`), not wall-clock, so a system time jump can neither
rewind nor fast-forward the send budget.
## Completion
All five phases are implemented and every gate is green. Per the scope-contract
note at the top, this file should be DELETED once the owed two-machine field
test (the checklist below) has been run — that deletion is a separate,
user-gated step, not part of the Phase 5 commit. Until then the plan stays as
the record of what shipped and what remains to verify on real hardware.
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.3"
#define MyAppVersion "0.6.6"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+1377 -269
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
//! Sender-side chat send status and pacing (chat-hardening Phase 5).
//!
//! Every RECEIVER admits our chat through a per-author token bucket
//! ([`CHAT_AUTHOR_BURST`] then 1/s) and silently drops what exceeds it, with no
//! acknowledgement wire. The only way the sender can be honest about fast
//! bursts is to never exceed that budget in the first place: sends past the
//! burst are queued locally (shown as "queued…") and trickled out at the
//! receivers' sustained rate. The pacer deliberately reuses the receiver
//! gate's own [`TokenBucket`] and constants so the two sides of the policy
//! cannot drift apart.
//!
//! Everything here is pure — `now_ms` is passed in, never read from a clock —
//! so every boundary is unit-testable.
use std::collections::VecDeque;
use crate::network::gossip::{CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, TokenBucket};
/// Send lifecycle of one locally authored chat message. Success is
/// [`SendStatus::Broadcast`] — "our signed frame was handed to the gossip
/// swarm" — deliberately NOT "delivered": PeerSpeak has no peer
/// acknowledgements, so the honest success presentation is no label at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendStatus {
/// Waiting in the local outbound queue for a pacer token.
Queued,
/// Handed to the core; the broadcast result has not come back yet.
Pending,
/// The signed broadcast reached the gossip swarm.
Broadcast,
/// The send failed; carries a short reason. The entry offers a Retry.
Failed(String),
}
/// Local-only send bookkeeping attached to our own chat entries. The id never
/// goes on the wire; it ties a `ChatSendResult` back to the matching echo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalSend {
pub id: u64,
pub status: SendStatus,
}
/// Sender-side pacer mirroring the receiver's per-author admission budget.
#[derive(Debug, Clone, Copy)]
pub struct SendPacer {
bucket: TokenBucket,
}
impl SendPacer {
pub fn new(now_ms: u64) -> Self {
Self {
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
}
}
/// Take one send token if the mirrored per-author budget allows it now.
pub fn try_send(&mut self, now_ms: u64) -> bool {
self.bucket
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms)
}
}
/// Pop the queued ids that may be dispatched now: strict front-of-queue order,
/// one pacer token each, stopping at the first refusal so a message can never
/// overtake an earlier one.
pub fn release_ready(queue: &mut VecDeque<u64>, pacer: &mut SendPacer, now_ms: u64) -> Vec<u64> {
let mut ready = Vec::new();
while !queue.is_empty() && pacer.try_send(now_ms) {
// The unwrap is safe: the loop condition just checked non-empty.
ready.push(queue.pop_front().unwrap());
}
ready
}
#[cfg(test)]
mod tests {
use super::*;
const T0: u64 = 1_000_000;
#[test]
fn pacer_allows_the_full_burst_then_refuses() {
let mut pacer = SendPacer::new(T0);
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
assert!(!pacer.try_send(T0));
}
#[test]
fn pacer_refills_at_one_per_second() {
let mut pacer = SendPacer::new(T0);
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
// 999ms is just under one token; 1000ms grants exactly one.
assert!(!pacer.try_send(T0 + 999));
assert!(pacer.try_send(T0 + 1000));
assert!(!pacer.try_send(T0 + 1000));
}
#[test]
fn release_ready_preserves_order_and_stops_at_refusal() {
let mut pacer = SendPacer::new(T0);
// Drain the burst so only refill tokens remain.
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
let mut queue: VecDeque<u64> = [10, 11, 12].into_iter().collect();
// 2 seconds of refill = 2 tokens: exactly the first two, in order.
let ready = release_ready(&mut queue, &mut pacer, T0 + 2000);
assert_eq!(ready, vec![10, 11]);
assert_eq!(queue, VecDeque::from([12]));
// No tokens left at the same instant.
assert!(release_ready(&mut queue, &mut pacer, T0 + 2000).is_empty());
assert_eq!(queue, VecDeque::from([12]));
}
#[test]
fn release_ready_empty_queue_consumes_no_tokens() {
let mut pacer = SendPacer::new(T0);
let mut queue = VecDeque::new();
assert!(release_ready(&mut queue, &mut pacer, T0).is_empty());
// The full burst must still be available.
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
}
}
+36
View File
@@ -478,6 +478,14 @@ pub struct AppConfig {
pub custom_sound_mic_toggle: Option<String>,
#[serde(default)]
pub custom_sound_reconnect_failed: Option<String>,
#[serde(default)]
pub custom_sound_chat_sent: Option<String>,
#[serde(default)]
pub custom_sound_chat_received: Option<String>,
#[serde(default)]
pub custom_sound_contact_online: Option<String>,
#[serde(default)]
pub custom_sound_contact_offline: Option<String>,
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
/// gates ALL chimes; these let the user silence individual events while the
/// master stays on. A chime plays only if the master AND its flag are true.
@@ -498,6 +506,14 @@ pub struct AppConfig {
pub sound_mic_toggle_enabled: bool,
#[serde(default = "default_true")]
pub sound_reconnect_failed_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_sent_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_received_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_online_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_offline_enabled: bool,
/// Optional override for the `pixelpass` binary location (screen share).
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)]
@@ -601,6 +617,10 @@ impl Default for AppConfig {
custom_sound_self_leave: None,
custom_sound_mic_toggle: None,
custom_sound_reconnect_failed: None,
custom_sound_chat_sent: None,
custom_sound_chat_received: None,
custom_sound_contact_online: None,
custom_sound_contact_offline: None,
sound_self_join_enabled: true,
sound_peer_join_enabled: true,
sound_peer_leave_enabled: true,
@@ -609,6 +629,10 @@ impl Default for AppConfig {
sound_self_leave_enabled: true,
sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true,
sound_chat_sent_enabled: true,
sound_chat_received_enabled: true,
sound_contact_online_enabled: true,
sound_contact_offline_enabled: true,
pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(),
@@ -639,6 +663,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
Sound::ChatSent => self.sound_chat_sent_enabled,
Sound::ChatReceived => self.sound_chat_received_enabled,
Sound::ContactOnline => self.sound_contact_online_enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled,
}
}
@@ -653,6 +681,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
}
}
@@ -940,6 +972,10 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert!(deserialized.custom_sound_chat_sent.is_none());
assert!(deserialized.custom_sound_chat_received.is_none());
assert!(deserialized.custom_sound_contact_online.is_none());
assert!(deserialized.custom_sound_contact_offline.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
+132
View File
@@ -0,0 +1,132 @@
//! Roster-bound chat identity (chat-hardening plan, Phase 2).
//!
//! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which
//! any insider could set to another member's name. This map is the antidote:
//! the core event task records each authenticated member's latest sanitized
//! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only
//! fire for a verified signed `Announce`), and chat renders under THAT name —
//! the embedded wire name is never displayed.
//!
//! Shared (`Arc<Mutex<…>>`) because eviction happens in two places: the event
//! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer
//! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its
//! chat stays admitted until the grace actually expires.
use iroh::EndpointId;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`):
/// insertions only follow cap-gated roster admissions, so this is pure defense
/// in depth against that invariant breaking.
const CHAT_ROSTER_CAP: usize = 32;
/// The authoritative id → display-name map for the current room. Cheap to
/// clone; all clones share one map.
#[derive(Debug, Clone, Default)]
pub struct ChatRoster {
names: Arc<Mutex<HashMap<EndpointId, String>>>,
}
impl ChatRoster {
/// Record (or refresh) a member's display name. The name is re-sanitized
/// here (idempotent — gossip ingress already did) and an empty result falls
/// back to the short node id so a chat line is never label-less. A NEW id
/// is refused past the cap; updates to a present id always land.
pub fn upsert(&self, id: EndpointId, name: &str) {
let clean = crate::sanitize::sanitize_name(name);
let label = if clean.is_empty() {
crate::short_id(&id.to_string())
} else {
clean
};
let mut names = self.names.lock().unwrap();
if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP {
names.insert(id, label);
}
}
/// Drop a member on graceful leave or terminal (grace-expired) eviction.
pub fn remove(&self, id: &EndpointId) {
self.names.lock().unwrap().remove(id);
}
/// The roster-bound name for an id, or `None` if the author is not a
/// current member — the caller must then drop the chat entirely.
pub fn name_of(&self, id: &EndpointId) -> Option<String> {
self.names.lock().unwrap().get(id).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
fn fresh_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn upsert_then_lookup_returns_sanitized_name() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "Alice");
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
// Bidi override / zero-width spoofing characters are stripped.
roster.upsert(a, "Al\u{202E}ice\u{200B}");
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
}
#[test]
fn name_update_affects_future_lookups() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "Alice");
roster.upsert(a, "Alice2");
assert_eq!(roster.name_of(&a), Some("Alice2".to_string()));
}
#[test]
fn unknown_author_has_no_name() {
let roster = ChatRoster::default();
roster.upsert(fresh_id(), "Alice");
assert_eq!(roster.name_of(&fresh_id()), None);
}
#[test]
fn removed_author_is_no_longer_a_member() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "Alice");
roster.remove(&a);
assert_eq!(roster.name_of(&a), None);
}
#[test]
fn empty_sanitized_name_falls_back_to_short_id() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "\u{0}\r\n\t ");
let label = roster.name_of(&a).unwrap();
assert!(!label.is_empty());
assert_eq!(label, crate::short_id(&a.to_string()));
}
#[test]
fn new_ids_are_refused_past_the_cap_but_updates_land() {
let roster = ChatRoster::default();
let first = fresh_id();
roster.upsert(first, "member");
for _ in 1..CHAT_ROSTER_CAP {
roster.upsert(fresh_id(), "member");
}
// A brand-new 33rd id is refused...
let overflow = fresh_id();
roster.upsert(overflow, "overflow");
assert_eq!(roster.name_of(&overflow), None);
// ...but an update to a present id still lands at the cap.
roster.upsert(first, "renamed");
assert_eq!(roster.name_of(&first), Some("renamed".to_string()));
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Byte/request budgets for AUTOMATIC chat-attachment fetches (Phase 3B).
//!
//! The four-permit semaphore bounds how many auto-fetch tasks run at once, but
//! not how much a peer can make us download over time: with permits released
//! after each transfer, an insider could stream distinct ≤4 MiB images
//! sequentially forever. This budget adds per-author and session (room-wide)
//! token buckets over both request COUNT and declared BYTES. Like the Phase 2
//! chat gate, time is passed in — never read from a clock — so every refill
//! boundary is unit-testable.
//!
//! Only the automatic path consults this; a user's explicit click (Save /
//! Download / Load image) is human-rate-limited and always allowed through to
//! the fetch (still subject to the transfer cap and cache/decoder budgets).
use iroh::EndpointId;
use std::collections::HashMap;
/// Per-author request burst: how many auto-fetches one author can trigger
/// back-to-back before refill pacing binds.
pub const AUTHOR_REQ_BURST: f64 = 8.0;
/// Per-author request refill: one recovered every 10 s.
pub const AUTHOR_REQ_REFILL_PER_MS: f64 = 1.0 / 10_000.0;
/// Per-author byte burst (declared sizes): a couple of full-size auto images
/// plus a normal working set.
pub const AUTHOR_BYTES_BURST: f64 = (16 * 1024 * 1024) as f64;
/// Per-author byte refill: 64 KiB/s (~one 4 MiB auto image per minute).
pub const AUTHOR_BYTES_REFILL_PER_MS: f64 = (64 * 1024) as f64 / 1000.0;
/// Session-wide request burst across all authors.
pub const SESSION_REQ_BURST: f64 = 16.0;
/// Session-wide request refill: one recovered every 5 s.
pub const SESSION_REQ_REFILL_PER_MS: f64 = 1.0 / 5_000.0;
/// Session-wide byte burst across all authors.
pub const SESSION_BYTES_BURST: f64 = (48 * 1024 * 1024) as f64;
/// Session-wide byte refill: 128 KiB/s.
pub const SESSION_BYTES_REFILL_PER_MS: f64 = (128 * 1024) as f64 / 1000.0;
/// Bound on the per-author bucket map. Authors are roster members (≤32 live),
/// so this tracks the roster plus recently departed; the least-recently-active
/// entry is pruned past the cap.
pub const AUTHOR_MAP_CAP: usize = 64;
/// A deterministic token bucket that can take a WEIGHTED cost (bytes), unlike
/// the unit-cost bucket in the gossip chat gate.
#[derive(Debug, Clone, Copy)]
struct WeightedBucket {
tokens: f64,
last_ms: u64,
}
impl WeightedBucket {
fn full(burst: f64, now_ms: u64) -> Self {
Self {
tokens: burst,
last_ms: now_ms,
}
}
/// Refill for elapsed time (capped at `burst`) without consuming.
fn refill(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) {
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
self.last_ms = now_ms;
}
fn has(&self, cost: f64) -> bool {
self.tokens >= cost
}
fn take(&mut self, cost: f64) {
self.tokens -= cost;
}
}
/// One author's pair of buckets plus last activity (for idle pruning).
#[derive(Debug)]
struct AuthorBudget {
reqs: WeightedBucket,
bytes: WeightedBucket,
last_seen_ms: u64,
}
/// Admission budget for automatic attachment fetches. All four buckets are
/// checked BEFORE any is consumed, so a rejection never burns tokens (no
/// refund bookkeeping — the check-then-take is atomic within `admit`).
#[derive(Debug)]
pub struct AutoFetchBudget {
session_reqs: WeightedBucket,
session_bytes: WeightedBucket,
authors: HashMap<EndpointId, AuthorBudget>,
}
impl AutoFetchBudget {
pub fn new(now_ms: u64) -> Self {
Self {
session_reqs: WeightedBucket::full(SESSION_REQ_BURST, now_ms),
session_bytes: WeightedBucket::full(SESSION_BYTES_BURST, now_ms),
authors: HashMap::new(),
}
}
/// Whether an auto-fetch of `size` declared bytes for `author` may start
/// now. Consumes one request token and `size` byte tokens from BOTH the
/// author's and the session's buckets — or nothing at all on rejection.
pub fn admit(&mut self, author: EndpointId, size: u64, now_ms: u64) -> bool {
self.prune(author, now_ms);
let entry = self.authors.entry(author).or_insert_with(|| AuthorBudget {
reqs: WeightedBucket::full(AUTHOR_REQ_BURST, now_ms),
bytes: WeightedBucket::full(AUTHOR_BYTES_BURST, now_ms),
last_seen_ms: now_ms,
});
entry.last_seen_ms = now_ms;
entry
.reqs
.refill(AUTHOR_REQ_BURST, AUTHOR_REQ_REFILL_PER_MS, now_ms);
entry
.bytes
.refill(AUTHOR_BYTES_BURST, AUTHOR_BYTES_REFILL_PER_MS, now_ms);
self.session_reqs
.refill(SESSION_REQ_BURST, SESSION_REQ_REFILL_PER_MS, now_ms);
self.session_bytes
.refill(SESSION_BYTES_BURST, SESSION_BYTES_REFILL_PER_MS, now_ms);
let cost = size as f64;
let ok = entry.reqs.has(1.0)
&& entry.bytes.has(cost)
&& self.session_reqs.has(1.0)
&& self.session_bytes.has(cost);
if ok {
let entry = self.authors.get_mut(&author).expect("just inserted");
entry.reqs.take(1.0);
entry.bytes.take(cost);
self.session_reqs.take(1.0);
self.session_bytes.take(cost);
}
ok
}
/// Keep the author map bounded: past the cap, drop the least-recently
/// active entry that isn't the author being admitted. A pruned author
/// returns with full buckets, but authors are roster-gated upstream, so
/// the map can't be churned by strangers.
fn prune(&mut self, keep: EndpointId, _now_ms: u64) {
while self.authors.len() >= AUTHOR_MAP_CAP {
let Some(victim) = self
.authors
.iter()
.filter(|(id, _)| **id != keep)
.min_by_key(|(_, b)| b.last_seen_ms)
.map(|(id, _)| *id)
else {
break;
};
self.authors.remove(&victim);
}
}
#[cfg(test)]
fn author_count(&self) -> usize {
self.authors.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
const T0: u64 = 1_000_000;
const MIB: u64 = 1024 * 1024;
fn author() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn author_request_burst_then_refill_recovers() {
let mut b = AutoFetchBudget::new(T0);
let a = author();
// Tiny sizes so only the REQUEST buckets can bind.
for _ in 0..AUTHOR_REQ_BURST as usize {
assert!(b.admit(a, 1, T0));
}
assert!(!b.admit(a, 1, T0), "author request burst exhausted");
// One request refills after 10 s.
assert!(b.admit(a, 1, T0 + 10_000));
assert!(!b.admit(a, 1, T0 + 10_000));
}
#[test]
fn author_byte_budget_binds_and_recovers() {
let mut b = AutoFetchBudget::new(T0);
let a = author();
// 4 × 4 MiB = the full 16 MiB author byte burst (well under the
// 8-request burst, so bytes are the binding constraint).
for _ in 0..4 {
assert!(b.admit(a, 4 * MIB, T0));
}
assert!(!b.admit(a, 4 * MIB, T0), "author byte burst exhausted");
// 64 KiB/s → a 4 MiB image is affordable again after 64 s (which also
// refills 6 request tokens, so bytes stay the binding constraint).
assert!(!b.admit(a, 4 * MIB, T0 + 32_000));
assert!(b.admit(a, 4 * MIB, T0 + 64_000));
}
#[test]
fn session_budget_binds_across_authors_without_burning_author_tokens() {
let mut b = AutoFetchBudget::new(T0);
// Three authors × 16 MiB exhausts the 48 MiB session byte burst even
// though each author is within their own budget.
for _ in 0..3 {
let a = author();
for _ in 0..4 {
assert!(b.admit(a, 4 * MIB, T0));
}
}
let fresh = author();
assert!(!b.admit(fresh, 4 * MIB, T0), "session bytes exhausted");
// The rejection consumed NOTHING: once the session refills enough for
// one image (4 MiB / 128 KiB/s = 32 s), the fresh author's own full
// burst is intact and admits immediately.
assert!(b.admit(fresh, 4 * MIB, T0 + 32_000));
}
#[test]
fn session_request_bucket_binds_across_authors() {
let mut b = AutoFetchBudget::new(T0);
// 16 tiny requests from distinct authors exhaust the session request
// burst while every author bucket stays nearly full.
for _ in 0..SESSION_REQ_BURST as usize {
assert!(b.admit(author(), 1, T0));
}
assert!(!b.admit(author(), 1, T0), "session requests exhausted");
assert!(b.admit(author(), 1, T0 + 5_000), "one recovers after 5 s");
}
#[test]
fn author_map_stays_bounded_pruning_least_recent() {
let mut b = AutoFetchBudget::new(T0);
// Session request refill would bind over a naive loop; space the
// admissions out so only the map bound is under test.
let mut t = T0;
let first = author();
assert!(b.admit(first, 1, t));
for _ in 0..(AUTHOR_MAP_CAP + 10) {
t += 10_000;
assert!(b.admit(author(), 1, t));
assert!(b.author_count() <= AUTHOR_MAP_CAP);
}
assert!(b.author_count() <= AUTHOR_MAP_CAP);
}
}
+92 -7
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");
self.decoder
.decode_fec(next_payload)
.or_else(|_| self.decoder.decode(None))
.ok()
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();
+48 -8
View File
@@ -66,15 +66,27 @@ pub enum CoreCommand {
/// Set what a recording captures (mixed / per-peer stems / both). Takes
/// effect on the next recording start. Sent at startup from config.
SetRecordingMode(RecordingMode),
/// Broadcast a room text-chat message. No-op when not in a call.
SendChat(String),
/// Broadcast a room text-chat message. `local_id` is the app's local-only
/// handle for this send — it never goes on the wire; the core echoes it back
/// in [`UiEvent::ChatSendResult`] so the UI can mark the matching local echo
/// honestly (chat-hardening Phase 5). Not being in a call is a FAILURE
/// result, not a silent no-op.
SendChat {
local_id: u64,
text: String,
},
/// Send a chat message carrying a file attachment. The app has already read +
/// capped the file and built the descriptor; core makes the bytes available
/// on the file plane and broadcasts the descriptor.
/// on the file plane and broadcasts the descriptor. `local_id` as in
/// [`CoreCommand::SendChat`].
SendChatFile {
local_id: u64,
text: String,
attachment: crate::files::ChatAttachment,
data: Vec<u8>,
/// Shared, not owned: the same allocation is retained by the UI cache
/// and handed to the serve store, so a 25 MiB attachment is held once,
/// not copied across UI / command queue / serve store (Phase 3C).
data: std::sync::Arc<Vec<u8>>,
},
/// Fetch a received attachment's bytes from its sender over the file plane
/// (used for on-demand file/chip downloads; images are auto-fetched on
@@ -224,8 +236,12 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
| CoreCommand::SendChat {
local_id: _,
text: _,
}
| CoreCommand::SendChatFile {
local_id: _,
text: _,
attachment: _,
data: _,
@@ -312,8 +328,12 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
| CoreCommand::SendChat {
local_id: _,
text: _,
}
| CoreCommand::SendChatFile {
local_id: _,
text: _,
attachment: _,
data: _,
@@ -417,6 +437,15 @@ pub enum UiEvent {
RecordingStopped {
path: String,
},
/// The outcome of one locally initiated chat send (chat-hardening Phase 5).
/// `error = None` means our signed broadcast was handed to the gossip swarm
/// — deliberately NOT a delivery/read receipt; PeerSpeak has no peer
/// acknowledgements. `local_id` is the app's own handle from the
/// `SendChat`/`SendChatFile` command and never appears on the wire.
ChatSendResult {
local_id: u64,
error: Option<String>,
},
/// A room text-chat message arrived from a peer (never our own — local
/// messages are echoed by the UI on send). `from` is the sender's node id
/// string, used to key their avatar (W4).
@@ -434,7 +463,15 @@ pub enum UiEvent {
AttachmentReady {
from: EndpointId,
id: crate::files::AttachmentId,
data: Vec<u8>,
data: std::sync::Arc<Vec<u8>>,
},
/// An attachment fetch task was spawned (auto or on demand). Lets the UI
/// show a real "loading" state instead of inferring it from cache absence —
/// absence now means NOT fetched (e.g. auto-fetch was skipped), which
/// renders a Load button rather than an indefinite "loading…" (Phase 3B).
AttachmentFetchStarted {
from: EndpointId,
id: crate::files::AttachmentId,
},
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed {
@@ -605,7 +642,10 @@ mod tests {
CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
CoreCommand::SendChat("hello".to_string()),
CoreCommand::SendChat {
local_id: 1,
text: "hello".to_string(),
},
];
for cmd in commands {
+400 -61
View File
@@ -1,4 +1,6 @@
pub mod chatroster;
pub mod connstats;
pub mod fetchbudget;
pub mod jitter;
pub mod messages;
mod recovery;
@@ -300,6 +302,9 @@ struct GraceExpiry<'a> {
jitter: &'a Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
ui_tx: &'a mpsc::Sender<UiEvent>,
recovery: Option<&'a RecoveryContext>,
/// Terminal eviction also revokes the peer's chat authority (Phase 2):
/// the roster-bound name map entry goes with the peer.
chat_roster: &'a chatroster::ChatRoster,
}
fn arm_grace_timer(
@@ -319,6 +324,7 @@ fn arm_grace_timer(
let timers_evict = timers.clone();
let seen_evict = seen_connected.clone();
let recovery_evict = expiry.recovery.cloned();
let chat_roster_evict = expiry.chat_roster.clone();
let handle = tokio::spawn(async move {
tokio::time::sleep(grace).await;
crate::log_msg(&format!("Reconnect grace expired for peer {:?}", peer_id));
@@ -330,6 +336,9 @@ fn arm_grace_timer(
}
transport_evict.remove_audio_sender(peer_id);
// Terminal eviction revokes chat authority too (Phase 2): a readmission
// via fresh authenticated Announce re-registers the name on PeerJoined.
chat_roster_evict.remove(&peer_id);
if let Some(recovery) = &recovery_evict {
// Revoke roster authority before the first await in teardown. A
// verified Announce racing after this point is then a PeerJoined and
@@ -555,6 +564,7 @@ pub struct ConnEventHandler {
jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
recovery: Option<RecoveryContext>,
grace: Duration,
chat_roster: chatroster::ChatRoster,
}
impl ConnEventHandler {
@@ -573,6 +583,7 @@ impl ConnEventHandler {
jitter,
recovery: None,
grace: RECONNECT_GRACE,
chat_roster: chatroster::ChatRoster::default(),
}
}
@@ -582,6 +593,13 @@ impl ConnEventHandler {
self
}
/// Share the room's chat roster so a grace-expiry eviction fired from the
/// transport's link-state path also revokes chat authority (Phase 2).
pub fn with_chat_roster(mut self, chat_roster: chatroster::ChatRoster) -> Self {
self.chat_roster = chat_roster;
self
}
fn with_recovery(mut self, recovery: RecoveryContext) -> Self {
self.recovery = Some(recovery);
self
@@ -605,6 +623,7 @@ impl ConnEventHandler {
jitter: &self.jitter,
ui_tx: &self.ui_tx,
recovery: self.recovery.as_ref(),
chat_roster: &self.chat_roster,
},
self.grace,
id,
@@ -888,6 +907,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
@@ -902,6 +1018,15 @@ const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4;
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
type InflightAttachments = Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
/// Milliseconds since the Unix epoch — the time source handed to the
/// deterministic auto-fetch budget (mirrors the gossip plane's timestamps).
fn unix_now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for
/// the task's lifetime and clears the in-flight `(author, id)` marker when the
/// fetch finishes (success OR failure), so the same image can be retried later.
@@ -918,11 +1043,22 @@ impl Drop for AutoFetchGuard {
}
/// Whether to AUTO-fetch a chat image attachment. Only authenticated roster
/// authors qualify (closing the non-roster injection vector), and a `(author,
/// id)` already being fetched is skipped (dedup). The concurrency bound itself is
/// enforced separately by the permit. Pure → unit-testable (Tier C F-02).
fn should_auto_fetch(is_image: bool, author_in_roster: bool, already_inflight: bool) -> bool {
is_image && author_in_roster && !already_inflight
/// authors qualify (closing the non-roster injection vector), only declared
/// sizes at or under [`crate::files::MAX_AUTO_IMAGE_BYTES`] (larger images get
/// a Load button instead, Phase 3B), and a `(author, id)` already being fetched
/// is skipped (dedup). The concurrency bound (permits) and the byte/request
/// budgets ([`fetchbudget::AutoFetchBudget`]) are enforced separately. Pure →
/// unit-testable (Tier C F-02).
fn should_auto_fetch(
is_image: bool,
author_in_roster: bool,
already_inflight: bool,
declared_size: u64,
) -> bool {
is_image
&& author_in_roster
&& !already_inflight
&& declared_size <= crate::files::MAX_AUTO_IMAGE_BYTES
}
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
@@ -945,6 +1081,12 @@ fn spawn_attachment_fetch(
tokio::spawn(async move {
// Held for the whole fetch; dropped here on completion (Tier C F-02).
let _guard = guard;
// Tell the UI a real fetch task exists for this key, so it can show a
// genuine loading state and dedup further clicks (Phase 3B). Sent from
// this task's channel handle, so it always precedes Ready/Failed.
let _ = ui_tx
.send(UiEvent::AttachmentFetchStarted { from, id: att.id })
.await;
match transport.fetch_attachment(from, &att).await {
Ok(data) => {
if is_image && crate::files::validate_image_bytes(&data).is_none() {
@@ -961,7 +1103,7 @@ fn spawn_attachment_fetch(
.send(UiEvent::AttachmentReady {
from,
id: att.id,
data,
data: Arc::new(data),
})
.await;
}
@@ -1335,6 +1477,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 +1698,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;
}
@@ -2225,17 +2378,27 @@ async fn run_core_loop(
Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_ATTACHMENT_FETCHES));
let inflight_attachments: InflightAttachments =
Arc::new(std::sync::Mutex::new(HashSet::new()));
// The authenticated chat roster for this room: id → roster-bound
// display name (chat-hardening Phase 2). Maintained from the same
// sequential event stream; shared because the detached grace-expiry
// timers (here and in the conn-event handler) must also revoke a
// terminally evicted peer's entry. Gates BOTH the chat text (only
// members render, under their roster name — never the wire name)
// and the automatic attachment fetch (Tier C F-02).
let chat_roster = chatroster::ChatRoster::default();
let chat_roster_events = chat_roster.clone();
let event_task = tokio::spawn(async move {
// The authenticated roster for this room, maintained from the
// same sequential event stream. Only its members may trigger an
// automatic attachment fetch (Tier C F-02).
let mut roster: HashSet<EndpointId> = HashSet::new();
let roster = chat_roster_events;
// Byte/request budgets for automatic attachment fetches
// (Phase 3B). Only this sequential task consults it, so it
// needs no lock; time is passed in for testability.
let mut auto_fetch_budget = fetchbudget::AutoFetchBudget::new(unix_now_ms());
while let Some(event) = room_events.recv().await {
match event {
RoomEvent::PeerJoined(peer_id, state) => {
// A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it.
roster.insert(peer_id);
roster.upsert(peer_id, &state.name);
cancel_grace_timer(&grace_timers_events, &peer_id);
recovery_events.cancel(peer_id);
transport_events.admit_audio_sender(peer_id);
@@ -2291,7 +2454,8 @@ async fn run_core_loop(
.await;
}
RoomEvent::PeerLeft(peer_id) => {
// Graceful leave — evict immediately.
// Graceful leave — evict immediately (chat authority
// and roster-bound name included).
roster.remove(&peer_id);
cancel_grace_timer(&grace_timers_events, &peer_id);
seen_connected_events.lock().unwrap().remove(&peer_id);
@@ -2304,6 +2468,11 @@ async fn run_core_loop(
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
}
RoomEvent::PeerUpdated(peer_id, state) => {
// Keep the roster-bound chat name current: a rename
// lands here as a state update (Phase 2). Future
// messages render under the new name; history keeps
// its stored snapshots.
roster.upsert(peer_id, &state.name);
// A re-announce means the peer is alive — cancel any
// pending grace timer. It may also carry a fresh
// address (peer back on a new network); refresh the
@@ -2351,11 +2520,26 @@ async fn run_core_loop(
}
RoomEvent::ChatMessage {
from,
name,
// The wire name is sender-claimed and NEVER rendered:
// the roster-bound name below is the author label
// (chat-hardening Phase 2, the impersonation fix).
name: _,
text,
ts: _,
attachment,
} => {
// Final-authority roster gate: only a current
// authenticated member (including one inside its
// reconnect grace) may create chat UI work. The
// gossip loop's early known-author gate is defense
// in depth; this map is what actually decides.
let Some(name) = roster.name_of(&from) else {
crate::log_msg(&format!(
"Dropped chat from non-roster author {}",
crate::short_id(&from.to_string())
));
continue;
};
// Auto-fetch image attachments so they render inline
// without a click; non-image files wait for an explicit
// FetchAttachment (the "Save" chip). The descriptor was
@@ -2374,9 +2558,14 @@ async fn run_core_loop(
inflight_attachments.lock().unwrap().contains(&key);
if should_auto_fetch(
is_image,
roster.contains(&from),
// Membership was proven by the roster name
// gate above, which drops non-members before
// any attachment handling.
true,
already_inflight,
) {
att.size,
) && auto_fetch_budget.admit(from, att.size, unix_now_ms())
{
// Reserve the dedup slot, then a permit. If the
// pool is exhausted, drop the auto-fetch (and the
// dedup marker) — the descriptor still shows and
@@ -2448,6 +2637,7 @@ async fn run_core_loop(
jitter: &jitter_events,
ui_tx: &ui_tx_events,
recovery: Some(&recovery_events),
chat_roster: &roster,
},
RECONNECT_GRACE,
peer_id,
@@ -2481,7 +2671,8 @@ async fn run_core_loop(
transport.clone(),
jitter.clone(),
)
.with_recovery(recovery_context);
.with_recovery(recovery_context)
.with_chat_roster(chat_roster.clone());
let conn_event_task = tokio::spawn(async move {
while let Some(event) = conn_events.recv().await {
conn_handler.handle(event).await;
@@ -2594,17 +2785,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 +2945,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 +2997,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;
}
@@ -3055,29 +3259,54 @@ async fn run_core_loop(
}
}
CoreCommand::SendChat(text) => {
if let Some(session) = &active_session
&& let Err(e) = session.room_state.send_chat(text, None).await
{
CoreCommand::SendChat { local_id, text } => {
// Honest result either way (Phase 5): no active session is a
// FAILURE the sender must see, not a silent drop.
let error = match &active_session {
Some(session) => session
.room_state
.send_chat(text, None)
.await
.err()
.map(|e| e.to_string()),
None => Some("not in a room".to_string()),
};
if let Some(e) = &error {
crate::log_msg(&format!("Failed to send chat: {e}"));
}
let _ = ui_tx
.send(UiEvent::ChatSendResult { local_id, error })
.await;
}
CoreCommand::SendChatFile {
local_id,
text,
attachment,
data,
} => {
if let Some(session) = &active_session {
// Make the bytes fetchable by room members, then broadcast the
// descriptor alongside the (possibly empty) caption text.
session
.transport
.serve_attachment(attachment.id, Arc::new(data));
if let Err(e) = session.room_state.send_chat(text, Some(attachment)).await {
crate::log_msg(&format!("Failed to send chat file: {e}"));
let error = match &active_session {
Some(session) => {
// Make the bytes fetchable by room members, then broadcast
// the descriptor alongside the (possibly empty) caption
// text. Re-serving the same id on a retry REPLACES the
// store entry (same Arc), never double-counts it.
session.transport.serve_attachment(attachment.id, data);
session
.room_state
.send_chat(text, Some(attachment))
.await
.err()
.map(|e| e.to_string())
}
None => Some("not in a room".to_string()),
};
if let Some(e) = &error {
crate::log_msg(&format!("Failed to send chat file: {e}"));
}
let _ = ui_tx
.send(UiEvent::ChatSendResult { local_id, error })
.await;
}
CoreCommand::FetchAttachment { from, attachment } => {
@@ -3322,10 +3551,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 +3578,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.
@@ -3487,15 +3811,30 @@ mod tests {
#[test]
fn auto_fetch_only_for_roster_images_not_already_inflight() {
// The happy path: a roster author's brand-new image attachment.
assert!(should_auto_fetch(true, true, false));
const OK_SIZE: u64 = 1024;
// The happy path: a roster author's brand-new, small-enough image.
assert!(should_auto_fetch(true, true, false, OK_SIZE));
// A non-image (generic file) never auto-fetches — it waits for "Save".
assert!(!should_auto_fetch(false, true, false));
assert!(!should_auto_fetch(false, true, false, OK_SIZE));
// A non-roster author (e.g. a sock puppet that never announced) is rejected,
// closing the F-02 unbounded-task vector.
assert!(!should_auto_fetch(true, false, false));
assert!(!should_auto_fetch(true, false, false, OK_SIZE));
// An identical (author,id) already being fetched is deduped.
assert!(!should_auto_fetch(true, true, true));
assert!(!should_auto_fetch(true, true, true, OK_SIZE));
// The declared-size gate (Phase 3B): at the cap auto-fetches, the first
// byte over requires a click.
assert!(should_auto_fetch(
true,
true,
false,
crate::files::MAX_AUTO_IMAGE_BYTES
));
assert!(!should_auto_fetch(
true,
true,
false,
crate::files::MAX_AUTO_IMAGE_BYTES + 1
));
}
#[test]
+341 -10
View File
@@ -22,6 +22,22 @@ pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
/// the byte cap. Applied via `image::Limits` when validating/decoding.
pub const MAX_IMAGE_PX: u32 = 4096;
/// Max total decoded pixels, applied on top of the per-side [`MAX_IMAGE_PX`]
/// limit. The per-side cap alone still admits a 4096×4096 ≈ 16.8 MP bitmap
/// (~64 MiB transient RGBA); this bounds the worst-case decode allocation while
/// still clearing common 12 MP phone photos (4032×3024 ≈ 12.2 MP).
pub const MAX_IMAGE_TOTAL_PIXELS: u64 = 14_000_000;
/// Max pixels per side of the downscaled inline preview handed to the renderer.
/// Original bytes are kept only for Save; the chat column never needs more than
/// this (it displays at ~260 px, and the lightbox at window size).
pub const IMAGE_PREVIEW_MAX_SIDE: u32 = 1600;
/// Largest declared size an image attachment may auto-fetch at. Anything larger
/// (or any skipped/evicted image) renders a "Load image" button instead; a
/// manual click may use the full [`MAX_ATTACHMENT_BYTES`] cap.
pub const MAX_AUTO_IMAGE_BYTES: u64 = 4 * 1024 * 1024;
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
/// the UI tidy; the real bytes are unaffected.
pub const MAX_FILENAME_LEN: usize = 96;
@@ -75,8 +91,14 @@ pub fn sanitize_filename(raw: &str) -> String {
.unwrap_or("")
.trim();
// Drop control chars; turn other whitespace into single spaces later.
let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
// Drop control chars and the same bidi/zero-width spoofing format chars
// stripped from display names (a U+202E override can visually reverse an
// extension, e.g. "photo\u{202E}gnp.exe" renders as "photoexe.png").
// Ordinary non-ASCII filenames pass through untouched.
let cleaned: String = base
.chars()
.filter(|c| !c.is_control() && !crate::sanitize::is_spoofing_format_char(*c))
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let collapsed = collapsed.trim_matches('.').trim();
@@ -164,20 +186,185 @@ pub fn classify(bytes: &[u8]) -> AttachmentKind {
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
/// `image` feature set; anything else returns `None` and the caller shows a chip.
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_PX);
limits.max_image_height = Some(MAX_IMAGE_PX);
let img = decode_image_bounded(bytes)?;
Some((img.width(), img.height()))
}
/// Shared bounded decode: header-check the dimensions (per-side AND total-pixel
/// limits) BEFORE decoding, then decode under `image::Limits` as defense in
/// depth. The precheck reads only the container header, so an over-limit bomb is
/// rejected without paying its decode cost.
fn decode_image_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let (w, h) = (img.width(), img.height());
let (w, h) = reader.into_dimensions().ok()?;
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
return None;
}
Some((w, h))
if u64::from(w) * u64::from(h) > MAX_IMAGE_TOTAL_PIXELS {
return None;
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_PX);
limits.max_image_height = Some(MAX_IMAGE_PX);
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
reader.limits(limits);
let img = reader.decode().ok()?;
// Decoded size must match the header the precheck approved.
if img.width() != w || img.height() != h {
return None;
}
Some(img)
}
/// A decoded, display-ready inline preview: RGBA pixels downscaled so neither
/// side exceeds [`IMAGE_PREVIEW_MAX_SIDE`]. `rgba.len() == width * height * 4`,
/// which is also the preview's decoded-budget weight in the attachment cache.
pub struct ImagePreview {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// Decode image bytes under the same limits as [`validate_image_bytes`] and
/// build the downscaled inline preview. The full-resolution bitmap exists only
/// transiently here; the renderer is never handed more than
/// [`IMAGE_PREVIEW_MAX_SIDE`]² pixels. Returns `None` for anything that fails
/// validation (caller falls back to a chip / failure row).
pub fn decode_preview(bytes: &[u8]) -> Option<ImagePreview> {
let img = decode_image_bounded(bytes)?;
let img = if img.width() > IMAGE_PREVIEW_MAX_SIDE || img.height() > IMAGE_PREVIEW_MAX_SIDE {
// `thumbnail` preserves aspect ratio within the bounding box.
img.thumbnail(IMAGE_PREVIEW_MAX_SIDE, IMAGE_PREVIEW_MAX_SIDE)
} else {
img
};
let rgba = img.into_rgba8();
let (width, height) = (rgba.width(), rgba.height());
Some(ImagePreview {
width,
height,
rgba: rgba.into_raw(),
})
}
/// Estimated decoded RGBA cost of a preview, the weight counted against the
/// attachment cache's decoded-byte budget (`width * height * 4`).
pub fn preview_rgba_cost(width: u32, height: u32) -> usize {
(width as usize)
.saturating_mul(height as usize)
.saturating_mul(4)
}
/// Read at most [`MAX_ATTACHMENT_BYTES`] bytes from `r`. Returns `Ok(None)` if
/// the source holds even one byte more (detected by reading cap + 1), so a huge
/// or unbounded source is never fully buffered. Pure over `Read` for tests; the
/// picker wraps it via [`read_file_capped`].
pub fn read_capped<R: std::io::Read>(r: R) -> std::io::Result<Option<Vec<u8>>> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut limited = r.take(MAX_ATTACHMENT_BYTES + 1);
limited.read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_ATTACHMENT_BYTES {
return Ok(None);
}
Ok(Some(buf))
}
/// Read a picked file, bounded by [`MAX_ATTACHMENT_BYTES`]. Checks metadata
/// first to reject an obviously-oversized file without opening it, but keeps the
/// bounded read regardless — metadata can race (the file can grow after the
/// check) or be unavailable through a portal. `Ok(None)` = over the cap.
pub fn read_file_capped(path: &std::path::Path) -> std::io::Result<Option<Vec<u8>>> {
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > MAX_ATTACHMENT_BYTES
{
return Ok(None);
}
read_capped(std::fs::File::open(path)?)
}
/// Cap on how many blobs the session serve store retains at once (sent chat
/// attachments plus the current/next broadcast music tracks).
pub const SERVED_FILES_MAX_ENTRIES: usize = 16;
/// Byte budget for the serve store. Without it, a sender's own session could
/// grow unbounded at up to [`MAX_ATTACHMENT_BYTES`] per send (Phase 3C).
pub const SERVED_FILES_MAX_BYTES: usize = 128 * 1024 * 1024;
/// Count- and byte-budgeted FIFO store of blobs we serve to room members over
/// the file plane. Evicting an id makes a later request for it read as an empty
/// body — the existing "sender no longer has the file" response — never stale
/// or aliased bytes. Pure (no locks/IO) so budgets are unit-testable; the
/// transport wraps it in its own mutex.
#[derive(Debug, Default)]
pub struct ServeStore {
entries: std::collections::HashMap<AttachmentId, std::sync::Arc<Vec<u8>>>,
/// Present ids in insertion order; the front is the eviction candidate.
order: std::collections::VecDeque<AttachmentId>,
total_bytes: usize,
}
impl ServeStore {
/// Insert or replace a blob, evicting oldest entries until the count and
/// byte budgets fit. Replacement keeps the id's age and subtracts the old
/// bytes before the new ones are counted. Returns `false` for a blob that
/// alone exceeds the byte budget (not stored; an existing entry under the
/// id is dropped rather than left stale).
pub fn insert(&mut self, id: AttachmentId, bytes: std::sync::Arc<Vec<u8>>) -> bool {
if let Some(old) = self.entries.get(&id) {
self.total_bytes -= old.len();
}
if bytes.len() > SERVED_FILES_MAX_BYTES {
if self.entries.remove(&id).is_some() {
self.order.retain(|k| k != &id);
}
return false;
}
let replacing = self.entries.contains_key(&id);
loop {
let count_full = !replacing && self.entries.len() >= SERVED_FILES_MAX_ENTRIES;
let bytes_full = self.total_bytes + bytes.len() > SERVED_FILES_MAX_BYTES;
if !count_full && !bytes_full {
break;
}
let Some(victim) = self.order.iter().find(|k| **k != id).copied() else {
break;
};
self.remove(&victim);
}
if !replacing {
self.order.push_back(id);
}
self.total_bytes += bytes.len();
self.entries.insert(id, bytes);
true
}
pub fn get(&self, id: &AttachmentId) -> Option<std::sync::Arc<Vec<u8>>> {
self.entries.get(id).cloned()
}
pub fn remove(&mut self, id: &AttachmentId) {
if let Some(old) = self.entries.remove(id) {
self.total_bytes -= old.len();
self.order.retain(|k| k != id);
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.order.clear();
self.total_bytes = 0;
}
#[cfg(test)]
fn len(&self) -> usize {
self.entries.len()
}
}
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
@@ -352,6 +539,87 @@ mod tests {
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
}
#[test]
fn sanitize_strips_bidi_and_zero_width_spoofing_chars() {
// U+202E would visually reverse the tail, disguising the extension.
assert_eq!(sanitize_filename("photo\u{202E}gnp.exe"), "photognp.exe");
assert_eq!(sanitize_filename("a\u{200B}b\u{FEFF}.txt"), "ab.txt");
// Ordinary Unicode filenames pass through.
assert_eq!(sanitize_filename("família_fotos.png"), "família_fotos.png");
assert_eq!(sanitize_filename("日本語.pdf"), "日本語.pdf");
}
/// Encode a solid PNG of the given dimensions for limit tests.
fn png_bytes(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn validate_image_rejects_excessive_total_pixels() {
// Both sides within MAX_IMAGE_PX, but 4096 * 4096 > MAX_IMAGE_TOTAL_PIXELS.
assert!(u64::from(MAX_IMAGE_PX) * u64::from(MAX_IMAGE_PX) > MAX_IMAGE_TOTAL_PIXELS);
assert_eq!(validate_image_bytes(&png_bytes(4096, 4096)), None);
// A 12 MP phone-photo shape passes both limits.
assert_eq!(
validate_image_bytes(&png_bytes(4032, 3024)),
Some((4032, 3024))
);
}
#[test]
fn preview_downscales_to_max_side_preserving_aspect() {
// Wide: 3200x400 → 1600x200.
let p = decode_preview(&png_bytes(3200, 400)).unwrap();
assert_eq!((p.width, p.height), (1600, 200));
assert_eq!(p.rgba.len(), preview_rgba_cost(1600, 200));
// Tall: 400x3200 → 200x1600.
let p = decode_preview(&png_bytes(400, 3200)).unwrap();
assert_eq!((p.width, p.height), (200, 1600));
// Square over the side cap: 2000x2000 → 1600x1600.
let p = decode_preview(&png_bytes(2000, 2000)).unwrap();
assert_eq!((p.width, p.height), (1600, 1600));
// At/under the cap is untouched.
let p = decode_preview(&png_bytes(1600, 900)).unwrap();
assert_eq!((p.width, p.height), (1600, 900));
let p = decode_preview(&png_bytes(4, 3)).unwrap();
assert_eq!((p.width, p.height), (4, 3));
assert_eq!(p.rgba.len(), preview_rgba_cost(4, 3));
}
#[test]
fn preview_rejects_what_validation_rejects() {
assert!(decode_preview(b"not an image").is_none());
assert!(decode_preview(&png_bytes(4096, 4096)).is_none());
}
#[test]
fn read_capped_stops_at_cap_plus_one() {
// Under the cap: full read.
let small = vec![7u8; 1024];
assert_eq!(
read_capped(std::io::Cursor::new(&small))
.unwrap()
.as_deref(),
Some(&small[..])
);
// Exactly at the cap: accepted. `repeat` is endless, `take` proves the
// reader is bounded rather than draining the source.
let at_cap = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES);
let got = read_capped(at_cap).unwrap().unwrap();
assert_eq!(got.len() as u64, MAX_ATTACHMENT_BYTES);
// One byte over: rejected, and only cap + 1 bytes were ever buffered
// (an unbounded source returns instead of allocating forever).
let over = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES + 1);
assert_eq!(read_capped(over).unwrap(), None);
let endless = std::io::repeat(1);
assert_eq!(read_capped(endless).unwrap(), None);
}
#[test]
fn human_size_units() {
assert_eq!(human_size(40), "40 B");
@@ -359,6 +627,69 @@ mod tests {
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
}
#[test]
fn serve_store_count_and_byte_eviction_fifo() {
use std::sync::Arc;
let mut s = ServeStore::default();
let blob = |n: u8, len: usize| ([n; 32], Arc::new(vec![n; len]));
// Count cap: entry 0 is evicted when the 17th arrives.
for n in 0..=SERVED_FILES_MAX_ENTRIES as u8 {
let (id, b) = blob(n, 8);
assert!(s.insert(id, b));
}
assert_eq!(s.len(), SERVED_FILES_MAX_ENTRIES);
assert!(s.get(&[0u8; 32]).is_none(), "oldest evicted by count");
assert!(s.get(&[1u8; 32]).is_some());
// Byte budget: two ~half-budget blobs evict everything older.
let half = SERVED_FILES_MAX_BYTES / 2;
let (a, ab) = blob(100, half);
let (b, bb) = blob(101, half);
assert!(s.insert(a, ab));
assert!(s.insert(b, bb));
assert!(s.get(&a).is_some());
assert!(s.get(&b).is_some());
assert!(s.get(&[1u8; 32]).is_none(), "evicted for byte budget");
// A third half-budget blob evicts `a` (oldest), keeps `b`.
let (c, cb) = blob(102, half);
assert!(s.insert(c, cb));
assert!(s.get(&a).is_none());
assert!(s.get(&b).is_some());
assert!(s.get(&c).is_some());
}
#[test]
fn serve_store_replacement_accounting_and_remove_clear() {
use std::sync::Arc;
let mut s = ServeStore::default();
let id = [9u8; 32];
assert!(s.insert(id, Arc::new(vec![1; SERVED_FILES_MAX_BYTES - 10])));
// Replacing the near-budget blob must subtract its old bytes first —
// otherwise this same-id replacement would evict itself.
assert!(s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES - 5])));
assert_eq!(s.get(&id).unwrap()[0], 2);
assert_eq!(s.len(), 1);
s.remove(&id);
assert!(s.get(&id).is_none());
// Removed bytes were released: the budget admits a full-size blob again.
assert!(s.insert(id, Arc::new(vec![3; SERVED_FILES_MAX_BYTES])));
s.clear();
assert_eq!(s.len(), 0);
assert!(s.insert(id, Arc::new(vec![4; SERVED_FILES_MAX_BYTES])));
}
#[test]
fn serve_store_rejects_individually_overweight_blob() {
use std::sync::Arc;
let mut s = ServeStore::default();
let id = [7u8; 32];
assert!(s.insert(id, Arc::new(vec![1; 8])));
assert!(!s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES + 1])));
// The stale small blob is gone too — a fetch reads "no longer has it",
// never old bytes under a replaced id.
assert!(s.get(&id).is_none());
assert_eq!(s.len(), 0);
}
#[test]
fn attachment_descriptor_round_trips_json() {
let a = ChatAttachment {
+566 -22
View File
@@ -36,7 +36,8 @@ const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
/// `author` can't validate (the attacker lacks the victim's secret key). `ts`
/// (sender-stamped unix-millis) is covered by the signature and gates replay
/// freshness — distinct from `GossipMessage::Chat.ts`, which is only for display.
/// freshness — distinct from `GossipMessage::Chat.ts`, an unauthenticated
/// duplicate kept only for wire compatibility and ignored on receive.
#[derive(Serialize, Deserialize, Clone)]
pub struct GossipPayload {
pub author: EndpointId,
@@ -254,6 +255,242 @@ impl ClockSkewMonitor {
}
}
/// Chat-hardening Phase 2 policy (docs/chat-hardening-plan.md): exact-replay
/// suppression bounds and the token-bucket rates that stop one admitted member
/// from monopolizing the event channel / UI with chat.
///
/// The replay cache is keyed on the payload's Ed25519 SIGNATURE bytes rather
/// than a separate BLAKE3 digest: ed25519 signing is deterministic (RFC 8032),
/// so the 64-byte signature is itself a collision-resistant fingerprint of the
/// exact signed bytes (topic + author + ts + msg) — same dedup power, zero new
/// dependencies. Entries are stamped with the payload's SIGNED timestamp and
/// pruned once that falls out of the freshness window, because `verify_gossip`
/// already rejects any replay whose signed `ts` is out-of-window — an expired
/// cache entry can no longer correspond to an admissible frame.
const CHAT_REPLAY_CACHE_CAP: usize = 1024;
/// Per-author chat budget: a burst of 8 absorbs a fast typist; 1 msg/s sustained
/// is well above real human chat rate while bounding a flooder to a trickle.
/// `pub(crate)` because the sender-side pacer (chat-hardening Phase 5) mirrors
/// this exact policy — one definition, so the two sides can never drift apart.
pub(crate) const CHAT_AUTHOR_BURST: f64 = 8.0;
pub(crate) const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0;
/// Room-wide chat budget across ALL authors, so a set of sock-puppet identities
/// can't multiply the per-author budget into unbounded event-channel pressure.
const CHAT_ROOM_BURST: f64 = 32.0;
const CHAT_ROOM_REFILL_PER_MS: f64 = 8.0 / 1000.0;
/// Bound on the per-author bucket map. Authors only enter it after the
/// known-author gate, so it tracks roughly the live roster plus recently
/// disconnected members; idle entries are pruned past this cap.
const CHAT_AUTHOR_BUCKETS_CAP: usize = 64;
/// Cooldown between logged chat rejections for one author (and one shared slot
/// for unknown authors), so a flood of rejected frames can't turn the log into
/// the new unbounded cost.
const CHAT_REJECT_LOG_COOLDOWN_MS: u64 = 10_000;
/// A minimal deterministic token bucket: time is passed in, never read from a
/// clock, so every boundary is unit-testable. Shared with the sender-side chat
/// pacer (`app::sendqueue`) so both sides of the rate policy use one mechanism.
#[derive(Debug, Clone, Copy)]
pub(crate) struct TokenBucket {
tokens: f64,
last_ms: u64,
}
impl TokenBucket {
pub(crate) fn full(burst: f64, now_ms: u64) -> Self {
Self {
tokens: burst,
last_ms: now_ms,
}
}
/// Refill for elapsed time (capped at `burst`), then take one token if
/// available. Returns whether a token was consumed.
pub(crate) fn try_take(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) -> bool {
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
self.last_ms = now_ms;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
/// Why an authenticated chat payload was still refused admission (Phase 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChatReject {
/// Author is neither a live gossip peer nor one mid-reconnect. The core
/// roster gate re-checks this as the final authority; this early copy just
/// refuses the work before any sanitize/attachment handling.
UnknownAuthor,
/// Exact byte-for-byte replay of an already-admitted signed chat.
Replay,
/// Per-author or room-wide token bucket empty.
RateLimited,
}
/// Per-author rate-limit + log-squelch state (see [`ChatIngressGate`]).
#[derive(Debug)]
struct AuthorGateState {
bucket: TokenBucket,
last_seen_ms: u64,
last_reject_log_ms: Option<u64>,
}
/// Chat admission gate run after `verify_gossip`, before any sanitize work or
/// event-channel send (chat-hardening plan Phase 2): known author → exact-replay
/// dedup → per-author + room token buckets, in that order. Dedup runs BEFORE the
/// buckets so a replayed frame can never consume tokens and starve the author's
/// own legitimate next message. Pure — callers pass `now_ms` — so every branch
/// is unit-testable.
#[derive(Debug)]
struct ChatIngressGate {
seen: HashSet<[u8; 64]>,
/// FIFO of (signature, signed ts) mirroring `seen`, for TTL + cap pruning.
seen_order: std::collections::VecDeque<([u8; 64], u64)>,
room: TokenBucket,
authors: HashMap<EndpointId, AuthorGateState>,
/// Shared squelch slot for unknown-author rejects (they have no map entry).
last_unknown_log_ms: Option<u64>,
}
impl ChatIngressGate {
fn new(now_ms: u64) -> Self {
Self {
seen: HashSet::new(),
seen_order: std::collections::VecDeque::new(),
room: TokenBucket::full(CHAT_ROOM_BURST, now_ms),
authors: HashMap::new(),
last_unknown_log_ms: None,
}
}
/// Admit or reject one verified chat payload. `known_author` is the caller's
/// live-or-reconnecting membership check; `payload_ts` is the SIGNED envelope
/// timestamp (already freshness-checked by `verify_gossip`).
fn admit(
&mut self,
known_author: bool,
author: EndpointId,
sig: &[u8; 64],
payload_ts: u64,
now_ms: u64,
) -> Result<(), ChatReject> {
if !known_author {
return Err(ChatReject::UnknownAuthor);
}
self.prune_replay_cache(now_ms);
if self.seen.contains(sig) {
return Err(ChatReject::Replay);
}
// Room bucket first: it is the cheaper aggregate bound, and consuming
// from it only when the author bucket also admits keeps the two in
// lockstep — so check both, then commit both.
let author_state = self.author_entry(author, now_ms);
let author_ok =
author_state
.bucket
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms);
if !author_ok {
return Err(ChatReject::RateLimited);
}
if !self
.room
.try_take(CHAT_ROOM_BURST, CHAT_ROOM_REFILL_PER_MS, now_ms)
{
// Refund the author token so a room-wide squeeze doesn't also debit
// every individual author's future budget.
if let Some(state) = self.authors.get_mut(&author) {
state.bucket.tokens = (state.bucket.tokens + 1.0).min(CHAT_AUTHOR_BURST);
}
return Err(ChatReject::RateLimited);
}
// Fully admitted — only now does the frame enter the replay cache, so a
// rate-limited legitimate message redelivered later is not misread as a
// replay of something that was never displayed.
self.seen.insert(*sig);
self.seen_order.push_back((*sig, payload_ts));
Ok(())
}
/// Whether this rejection should be logged: at most one log line per author
/// (or one shared line for unknown authors) per cooldown window.
fn should_log_reject(&mut self, known_author: bool, author: EndpointId, now_ms: u64) -> bool {
let slot = if known_author {
self.authors
.get_mut(&author)
.map(|state| &mut state.last_reject_log_ms)
} else {
Some(&mut self.last_unknown_log_ms)
};
let Some(slot) = slot else {
return true;
};
let due =
slot.is_none_or(|last| now_ms.saturating_sub(last) >= CHAT_REJECT_LOG_COOLDOWN_MS);
if due {
*slot = Some(now_ms);
}
due
}
/// Drop cache entries whose signed timestamp fell out of the freshness
/// window (they can no longer pass `verify_gossip`), then enforce the hard
/// cap FIFO-oldest-first.
fn prune_replay_cache(&mut self, now_ms: u64) {
let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
while let Some((sig, ts)) = self.seen_order.front() {
if *ts >= floor && self.seen_order.len() < CHAT_REPLAY_CACHE_CAP {
break;
}
self.seen.remove(sig);
self.seen_order.pop_front();
}
}
/// Get-or-create the author's bucket state, pruning the map if a flood of
/// short-lived identities has grown it past its cap: idle authors (nothing
/// admitted within the freshness window) go first, then oldest-seen.
fn author_entry(&mut self, author: EndpointId, now_ms: u64) -> &mut AuthorGateState {
if !self.authors.contains_key(&author) && self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
self.authors.retain(|_, state| state.last_seen_ms >= floor);
while self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
if let Some(oldest) = self
.authors
.iter()
.min_by_key(|(_, state)| state.last_seen_ms)
.map(|(id, _)| *id)
{
self.authors.remove(&oldest);
} else {
break;
}
}
}
let state = self.authors.entry(author).or_insert(AuthorGateState {
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
last_seen_ms: now_ms,
last_reject_log_ms: None,
});
state.last_seen_ms = now_ms;
state
}
/// Drop an author's limiter state alongside its roster eviction (a signed
/// `Leave`), so the map stays bounded by the roster's own churn.
fn evict_author(&mut self, author: &EndpointId) {
self.authors.remove(author);
}
}
/// Maximum number of distinct peers we hold in a room roster at once.
///
/// Everyone with the room ticket is an authenticated *insider*: a signature only
@@ -524,6 +761,7 @@ impl RoomState for IrohGossipState {
));
let mut state_mutations_seen = HashMap::new();
let mut clock_skew_monitor = ClockSkewMonitor::default();
let mut chat_gate = ChatIngressGate::new(now_millis());
// Broadcast initial state
let initial_payload = {
@@ -793,6 +1031,10 @@ impl RoomState for IrohGossipState {
.lock()
.unwrap()
.remove(&payload.author);
// Roster eviction also drops the author's
// chat-limiter state, keeping that map
// bounded by roster churn (Phase 2).
chat_gate.evict_author(&payload.author);
if removed || was_disconnected {
let _ = event_tx
.send(RoomEvent::PeerLeft(payload.author))
@@ -802,9 +1044,43 @@ impl RoomState for IrohGossipState {
GossipMessage::Chat {
name,
text,
ts,
// The inner ts is an unauthenticated duplicate of the
// signed envelope ts — ignored entirely; the envelope
// value is what RoomEvent carries (Phase 2).
ts: _,
attachment,
} => {
// Phase 2 ingress admission, BEFORE any sanitize or
// attachment work: known author (live or
// mid-reconnect — the core roster gate is the final
// authority) → exact-replay dedup keyed on the
// signature → per-author + room token buckets.
let known_author =
peers.lock().unwrap().contains_key(&payload.author)
|| disconnected_peers
.lock()
.unwrap()
.contains(&payload.author);
if let Err(reject) = chat_gate.admit(
known_author,
payload.author,
&payload.sig.to_bytes(),
payload.ts,
received_now_ms,
) {
if chat_gate.should_log_reject(
known_author,
payload.author,
received_now_ms,
) {
crate::log_msg(&format!(
"Dropped chat from author={}: {:?}",
crate::short_id(&payload.author.to_string()),
reject
));
}
continue;
}
crate::log_msg(&format!(
"Gossip chat from author={:?}",
payload.author
@@ -819,12 +1095,30 @@ impl RoomState for IrohGossipState {
a.name = crate::files::sanitize_filename(&a.name);
Some(a)
});
// Chat text policy at INGRESS: reject raw text
// over the byte ceiling before spending any
// sanitize work on it (a compliant sender
// sanitizes before signing), and drop a message
// with neither visible text nor an attachment.
let Some(text) = crate::sanitize::admit_chat_text(
&text,
attachment.is_some(),
) else {
crate::log_msg(&format!(
"Dropped out-of-policy chat from author={:?} (oversized or empty)",
payload.author
));
continue;
};
let _ = event_tx
.send(RoomEvent::ChatMessage {
from: payload.author,
name,
text,
ts,
// Only the SIGNED envelope timestamp travels
// downstream (never used for replay/ordering —
// the gate above already handled replay).
ts: payload.ts,
attachment,
})
.await;
@@ -966,6 +1260,14 @@ impl RoomState for IrohGossipState {
text: String,
attachment: Option<crate::files::ChatAttachment>,
) -> Result<(), NetError> {
// Enforce the chat text policy at the SIGN point, not only in the UI, so
// a future non-UI caller can't sign an out-of-policy body (chat-hardening
// plan Phase 1). Idempotent over the UI's own sanitize pass.
let text = crate::sanitize::sanitize_chat(&text);
if text.is_empty() && attachment.is_none() {
// Nothing visible to send — not an error, just nothing to do.
return Ok(());
}
let name = {
let guard = self.self_state.lock().unwrap();
match guard.as_ref() {
@@ -977,26 +1279,29 @@ impl RoomState for IrohGossipState {
let sender_opt = self.active_sender.lock().unwrap().clone();
let topic_opt = *self.active_topic_bytes.lock().unwrap();
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
let payload = sign_gossip(
&self.secret_key,
&topic,
// A missing sender/topic or an encode failure is a real send failure the
// caller must see (chat-hardening Phase 5) — silently returning Ok here
// would let the UI present an unsent message as broadcast.
let (Some(sender), Some(topic)) = (sender_opt, topic_opt) else {
return Err(NetError::Other("Not in a room".to_string()));
};
let payload = sign_gossip(
&self.secret_key,
&topic,
ts,
GossipMessage::Chat {
name,
text,
ts,
GossipMessage::Chat {
name,
text,
ts,
attachment,
},
);
if let Ok(bytes) = serde_json::to_vec(&payload) {
sender
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))?;
}
}
Ok(())
attachment,
},
);
let bytes = serde_json::to_vec(&payload)
.map_err(|e| NetError::Other(format!("Failed to encode chat: {e}")))?;
sender
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))
}
async fn leave(&self) -> Result<(), NetError> {
@@ -1664,4 +1969,243 @@ mod tests {
5
));
}
// ---- Chat-hardening Phase 2: ingress gate (replay dedup + token buckets) ----
/// Distinct opaque "signature" bytes; the gate never inspects them beyond
/// equality, so a counter-stamped array stands in for a real signature.
fn sig(n: u64) -> [u8; 64] {
let mut bytes = [0u8; 64];
bytes[..8].copy_from_slice(&n.to_le_bytes());
bytes
}
const T0: u64 = 1_000_000_000_000;
#[test]
fn token_bucket_burst_and_refill_boundaries() {
let mut bucket = TokenBucket::full(CHAT_AUTHOR_BURST, T0);
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
}
// Burst exhausted at the same instant.
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
// 999 ms refills just under one token at 1/s…
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 999));
// …a full second refills exactly one (999 ms already banked 0.999 of it,
// so take at the accumulated boundary).
assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
}
#[test]
fn chat_gate_rejects_unknown_author() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
assert_eq!(
gate.admit(false, author, &sig(1), T0, T0),
Err(ChatReject::UnknownAuthor)
);
// Same frame from a known author is fine — nothing was consumed above.
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
}
#[test]
fn chat_gate_suppresses_exact_replay_but_admits_distinct_same_ms() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
// Two DISTINCT chats signed in the same millisecond both land…
assert_eq!(gate.admit(true, author, &sig(2), T0, T0), Ok(()));
// …but the byte-identical frame is a replay, from any deliverer.
assert_eq!(
gate.admit(true, author, &sig(1), T0, T0),
Err(ChatReject::Replay)
);
}
#[test]
fn chat_gate_author_burst_then_refill() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
for n in 0..CHAT_AUTHOR_BURST as u64 {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
}
assert_eq!(
gate.admit(true, author, &sig(99), T0, T0),
Err(ChatReject::RateLimited)
);
// One second later the author has exactly one more message.
assert_eq!(gate.admit(true, author, &sig(100), T0, T0 + 1_000), Ok(()));
assert_eq!(
gate.admit(true, author, &sig(101), T0, T0 + 1_000),
Err(ChatReject::RateLimited)
);
}
#[test]
fn chat_gate_replay_never_consumes_tokens() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
for n in 0..CHAT_AUTHOR_BURST as u64 {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
}
// Replays of an admitted frame while exhausted report Replay (dedup runs
// BEFORE the buckets) and burn no tokens…
for _ in 0..50 {
assert_eq!(
gate.admit(true, author, &sig(0), T0, T0 + 1_000),
Err(ChatReject::Replay)
);
}
// …so the token refilled at +1s is still there for a NEW message.
assert_eq!(gate.admit(true, author, &sig(200), T0, T0 + 1_000), Ok(()));
}
#[test]
fn chat_gate_rate_limited_frame_is_not_marked_replayed() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
for n in 0..CHAT_AUTHOR_BURST as u64 {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
}
// Rejected for rate only — NOT entered into the replay cache…
assert_eq!(
gate.admit(true, author, &sig(300), T0, T0),
Err(ChatReject::RateLimited)
);
// …so the same signed frame redelivered after refill is admitted once.
assert_eq!(gate.admit(true, author, &sig(300), T0, T0 + 1_000), Ok(()));
}
#[test]
fn chat_gate_room_bucket_bounds_sock_puppet_authors() {
let mut gate = ChatIngressGate::new(T0);
// 40 distinct authors, one message each, same instant: per-author buckets
// are all full, so only the room-wide burst bounds admission.
let mut admitted = 0;
for n in 0..40u64 {
if gate.admit(true, fresh_id(), &sig(n), T0, T0).is_ok() {
admitted += 1;
}
}
assert_eq!(admitted, CHAT_ROOM_BURST as usize);
// The room refills at 8/s: exactly 8 more land a second later, even from
// fresh authors whose own buckets are full — the room bound decides.
let mut late_admitted = 0;
for n in 100..120u64 {
if gate
.admit(true, fresh_id(), &sig(n), T0, T0 + 1_000)
.is_ok()
{
late_admitted += 1;
}
}
assert_eq!(late_admitted, 8);
// Chat admission being room-bounded is what keeps the event channel
// available for control messages: an Announce is gated independently.
let mut seen = HashMap::new();
assert!(admit_state_mutation(
&mut seen,
fresh_id(),
&GossipMessage::Leave,
T0 + 1_000
));
}
#[test]
fn chat_gate_room_reject_refunds_the_author_token() {
let mut gate = ChatIngressGate::new(T0);
// Author A drains the whole room burst alone? No — its own burst is 8.
// Use 4 authors × 8 to empty the room exactly.
let mut n = 0u64;
for _ in 0..4 {
let author = fresh_id();
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
n += 1;
}
}
// A 5th author is room-rejected 8 times, but its own bucket is refunded
// each time…
let victim = fresh_id();
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert_eq!(
gate.admit(true, victim, &sig(n), T0, T0),
Err(ChatReject::RateLimited)
);
n += 1;
}
// …so when the room refills, the victim still has its FULL burst.
let mut admitted = 0;
for _ in 0..CHAT_AUTHOR_BURST as usize {
if gate.admit(true, victim, &sig(n), T0, T0 + 1_000).is_ok() {
admitted += 1;
}
n += 1;
}
assert_eq!(admitted, CHAT_AUTHOR_BURST as usize);
}
#[test]
fn chat_replay_cache_prunes_by_ttl_and_cap() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
// TTL: an admitted frame's entry is dropped once its SIGNED ts falls out
// of the freshness window (it could no longer pass verify_gossip anyway).
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
assert_eq!(gate.seen.len(), 1);
gate.prune_replay_cache(T0 + GOSSIP_FRESHNESS_MS + 1);
assert!(gate.seen.is_empty() && gate.seen_order.is_empty());
// Hard cap: stuff the cache directly (admission itself is rate-limited
// far below the cap) and verify FIFO-oldest eviction bounds it.
for n in 0..(CHAT_REPLAY_CACHE_CAP as u64 + 100) {
gate.seen.insert(sig(n));
gate.seen_order.push_back((sig(n), T0));
}
gate.prune_replay_cache(T0);
assert!(gate.seen_order.len() < CHAT_REPLAY_CACHE_CAP);
assert_eq!(gate.seen.len(), gate.seen_order.len());
// The oldest entries went first.
assert!(!gate.seen.contains(&sig(0)));
assert!(gate.seen.contains(&sig(CHAT_REPLAY_CACHE_CAP as u64 + 99)));
}
#[test]
fn chat_gate_author_bucket_map_stays_bounded() {
let mut gate = ChatIngressGate::new(T0);
for n in 0..(CHAT_AUTHOR_BUCKETS_CAP as u64 * 2) {
let _ = gate.admit(true, fresh_id(), &sig(n), T0, T0 + n);
}
assert!(gate.authors.len() <= CHAT_AUTHOR_BUCKETS_CAP);
}
#[test]
fn chat_gate_evict_author_drops_limiter_state() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
assert!(gate.authors.contains_key(&author));
gate.evict_author(&author);
assert!(!gate.authors.contains_key(&author));
}
#[test]
fn chat_gate_reject_logging_is_squelched_per_author() {
let mut gate = ChatIngressGate::new(T0);
let author = fresh_id();
// Establish limiter state, then exhaust it.
for n in 0..=CHAT_AUTHOR_BURST as u64 {
let _ = gate.admit(true, author, &sig(n), T0, T0);
}
assert!(gate.should_log_reject(true, author, T0));
assert!(!gate.should_log_reject(true, author, T0 + 1));
assert!(gate.should_log_reject(true, author, T0 + CHAT_REJECT_LOG_COOLDOWN_MS));
// Unknown authors share one squelch slot (they have no map entry).
let stranger = fresh_id();
assert!(gate.should_log_reject(false, stranger, T0));
assert!(!gate.should_log_reject(false, fresh_id(), T0 + 1));
}
}
+18 -4
View File
@@ -69,7 +69,9 @@ struct Shared {
/// the random attachment id. Populated when we send a chat file; read by the
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
/// blob is already byte-capped at send time.
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
/// Blobs we serve to room members, bounded by count and byte budgets
/// (Phase 3C) — an evicted id reads as "sender no longer has the file".
served_files: StdMutex<crate::files::ServeStore>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>,
@@ -518,7 +520,7 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
let Some(id) = crate::files::parse_request(&req) else {
return Ok(());
};
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
let blob = shared.served_files.lock().unwrap().get(&id);
if let Some(blob) = blob {
let _ = send.write_all(&blob).await;
}
@@ -563,7 +565,7 @@ impl IrohTransport {
peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()),
served_files: StdMutex::new(HashMap::new()),
served_files: StdMutex::new(crate::files::ServeStore::default()),
incoming_tx,
conn_events_tx,
});
@@ -634,7 +636,9 @@ impl IrohTransport {
/// session (served by the [`FileRouter`] handler). Called by core when we
/// send a chat file. The blob is cleared on leave.
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
self.shared.served_files.lock().unwrap().insert(id, bytes);
if !self.shared.served_files.lock().unwrap().insert(id, bytes) {
crate::log_msg("Transport: refused to serve an over-budget blob");
}
}
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
@@ -676,6 +680,8 @@ impl IrohTransport {
send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
// `read_to_end(size)` errors if the stream exceeds `size`, rejecting an
// overlong transfer; the exact-length check below rejects a short one.
let read = recv.read_to_end(size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await
@@ -686,6 +692,14 @@ impl IrohTransport {
"file fetch: sender no longer has the file".to_string(),
));
}
// Exact transfer required (Phase 3C): a truncated body must not be
// cached/saved/decoded as if it were the declared attachment.
if bytes.len() as u64 != size {
return Err(NetError::Other(format!(
"file fetch: incomplete transfer ({} of {size} bytes)",
bytes.len()
)));
}
Ok(bytes)
}
+4 -3
View File
@@ -152,9 +152,10 @@ pub enum RoomEvent {
author: EndpointId,
skew_ms: i64,
},
/// A peer sent a room text-chat message. Carries the sender's id, their
/// display name (embedded so it shows even without a presence entry), the
/// text, and a sender-stamped millisecond timestamp.
/// A peer sent a room text-chat message. Carries the sender's id, the
/// sender-CLAIMED display name (untrusted; the core replaces it with the
/// roster-bound name before the UI sees it — chat-hardening Phase 2), the
/// text, and the signed envelope timestamp (display only, never ordering).
ChatMessage {
from: EndpointId,
name: String,
+25 -1
View File
@@ -80,6 +80,14 @@ pub enum Sound {
MicToggle,
/// Reconnect failed / peer evicted.
ReconnectFailed,
/// One of our chat messages was broadcast to the room.
ChatSent,
/// A chat message from another participant was admitted.
ChatReceived,
/// A saved contact was detected online on the home screen.
ContactOnline,
/// A saved contact previously seen online went offline on the home screen.
ContactOffline,
}
impl Sound {
@@ -93,10 +101,14 @@ impl Sound {
Sound::SelfLeave,
Sound::MicToggle,
Sound::ReconnectFailed,
Sound::ChatSent,
Sound::ChatReceived,
Sound::ContactOnline,
Sound::ContactOffline,
];
/// Number of distinct notification events.
pub const COUNT: usize = 8;
pub const COUNT: usize = 12;
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
fn index(self) -> usize {
@@ -109,6 +121,10 @@ impl Sound {
Sound::SelfLeave => 5,
Sound::MicToggle => 6,
Sound::ReconnectFailed => 7,
Sound::ChatSent => 8,
Sound::ChatReceived => 9,
Sound::ContactOnline => 10,
Sound::ContactOffline => 11,
}
}
@@ -123,6 +139,10 @@ impl Sound {
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
}
}
@@ -137,6 +157,10 @@ impl Sound {
Sound::SelfLeave => "self-leave",
Sound::MicToggle => "mic-toggle",
Sound::ReconnectFailed => "reconnect-failed",
Sound::ChatSent => "chat-sent",
Sound::ChatReceived => "chat-received",
Sound::ContactOnline => "contact-online",
Sound::ContactOffline => "contact-offline",
}
}
}
+423 -124
View File
@@ -3,28 +3,40 @@
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
//! untrusted and spoofable, yet they're rendered directly in the roster. This
//! module cleans a name at the gossip ingest point so every downstream consumer
//! gets a safe value (security finding S4). Chat text has its own sanitizer in
//! the UI layer (`app::sanitize_chat`).
//! gets a safe value (security finding S4). Chat text policy ([`sanitize_chat`],
//! [`cap_chat_input`], [`admit_chat_text`]) also lives here so the UI, the gossip
//! sign point, and the gossip ingress all enforce the same ceilings.
/// Max characters kept for a peer's display name after sanitizing. Names are
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
/// the roster readable.
pub const NAME_MAX_CHARS: usize = 48;
/// Bidirectional override/isolate format characters (`General_Category=Cf`, NOT
/// caught by [`char::is_control`]) that can visually reorder surrounding text.
/// Stripped even from expressive chat bodies (security finding S14): unlike the
/// benign zero-width joiners/marks, these let a sender make rendered text read
/// differently from what was actually sent.
pub(crate) fn is_bidi_override_char(c: char) -> bool {
matches!(c,
'\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
)
}
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
/// rendered name even though they are NOT caught by [`char::is_control`]:
/// bidirectional overrides/isolates (text-direction spoofing) and
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
/// explicitly so the sanitizer stays dependency-free (std exposes no category
/// query). Stripped outright rather than replaced.
fn is_spoofing_format_char(c: char) -> bool {
matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
| '\u{FEFF}' // BOM / zero-width no-break space
)
pub(crate) fn is_spoofing_format_char(c: char) -> bool {
is_bidi_override_char(c)
|| matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
| '\u{FEFF}' // BOM / zero-width no-break space
)
}
/// Max characters kept for a broadcast game-presence label after sanitizing
@@ -73,15 +85,98 @@ pub fn sanitize_game_label(input: &str) -> String {
out
}
/// A piece of a chat message after URL detection: literal text or a link.
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Segment {
/// Plain text to render as-is.
Text(String),
/// A detected URL to render as a clickable link (also its href).
Link(String),
/// Max characters kept for a single chat message after sanitizing.
pub const CHAT_MSG_MAX_CHARS: usize = 2000;
/// Max UTF-8 bytes kept for a single chat message, enforced alongside
/// [`CHAT_MSG_MAX_CHARS`] (2,000 four-byte scalars would otherwise reach 8,000
/// bytes). This is also the ingress bound: signed peers never produce more, so
/// raw incoming text above it is rejected outright (see [`admit_chat_text`]).
pub const CHAT_MSG_MAX_BYTES: usize = 8 * 1024;
/// Sanitize a chat message body, applied to BOTH our outgoing text (before local
/// echo, and again at the gossip sign point) and incoming peer text (untrusted —
/// a buggy/malicious sender could include control characters or an enormous
/// payload). Single pass: bidi overrides/isolates are stripped outright (S14 —
/// they can visually reorder the rendered line), control characters become
/// spaces, any whitespace run collapses to a single space, the ends are trimmed,
/// and both the character and UTF-8 byte ceilings are enforced without ever
/// splitting a scalar. Message bodies deliberately keep the OTHER format
/// characters (ZWJ/ZWNJ/LRM/RLM etc.) that the short-label sanitizers strip —
/// chat is expressive text, not a label, and those are needed for emoji
/// sequences and joining scripts. Returns `""` for input with no visible text
/// (callers drop empty messages). Idempotent, so layered application converges
/// on the same result.
pub fn sanitize_chat(input: &str) -> String {
let mut out = String::new();
let mut chars = 0usize;
let mut pending_space = false;
for c in input.chars() {
if is_bidi_override_char(c) {
continue;
}
let c = if c.is_control() { ' ' } else { c };
if c.is_whitespace() {
// Trim: only mark a separator once visible text exists; a trailing
// run is never emitted because the space lands with the NEXT char.
pending_space = !out.is_empty();
continue;
}
let sep = usize::from(pending_space);
if chars + sep + 1 > CHAT_MSG_MAX_CHARS
|| out.len() + sep + c.len_utf8() > CHAT_MSG_MAX_BYTES
{
break;
}
if pending_space {
out.push(' ');
chars += 1;
pending_space = false;
}
out.push(c);
chars += 1;
}
out
}
/// Cap the LIVE chat-input text (typing, clipboard/primary-selection paste,
/// context-menu paste) at the chat ceilings. Unlike [`sanitize_chat`] this
/// preserves the user's whitespace exactly — normalization stays a submit-time
/// operation so the visible text never jumps while editing — and only truncates,
/// always on a scalar boundary. Returns the input unchanged when within bounds.
pub fn cap_chat_input(input: String) -> String {
if input.len() <= CHAT_MSG_MAX_BYTES && input.chars().count() <= CHAT_MSG_MAX_CHARS {
return input;
}
let mut out = String::new();
for (chars, c) in input.chars().enumerate() {
if chars >= CHAT_MSG_MAX_CHARS || out.len() + c.len_utf8() > CHAT_MSG_MAX_BYTES {
break;
}
out.push(c);
}
out
}
/// Gossip-ingress admission for an untrusted incoming chat body. `None` drops
/// the message: raw text over the byte ceiling is rejected BEFORE any
/// sanitization work (a compliant sender sanitizes before signing, so oversized
/// text is a protocol violation, not something to repair), and a message with
/// neither visible text nor an attachment carries nothing to show. Otherwise
/// yields the sanitized (possibly empty, attachment-only) body to forward.
pub fn admit_chat_text(raw: &str, has_attachment: bool) -> Option<String> {
if raw.len() > CHAT_MSG_MAX_BYTES {
return None;
}
let text = sanitize_chat(raw);
(!text.is_empty() || has_attachment).then_some(text)
}
/// Max clickable links rendered per chat message. Later URL candidates stay
/// selectable plain text — bounds both the span count a message can force the
/// renderer to build and the opener targets one line can carry.
pub const CHAT_MSG_MAX_LINKS: usize = 8;
/// Trailing characters commonly adjacent to a URL in prose that should NOT be
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
fn is_url_trailing_punct(c: char) -> bool {
@@ -91,42 +186,85 @@ fn is_url_trailing_punct(c: char) -> bool {
)
}
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`,
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`
/// (ASCII-case-insensitive, so a sentence-capitalized "Http://…" still counts),
/// scanning only on char boundaries so slicing is always safe.
fn find_scheme(s: &str) -> Option<usize> {
s.char_indices().find_map(|(i, _)| {
let tail = &s[i..];
(tail.starts_with("http://") || tail.starts_with("https://")).then_some(i)
let matches_prefix = |p: &str| {
tail.get(..p.len())
.is_some_and(|t| t.eq_ignore_ascii_case(p))
};
(matches_prefix("http://") || matches_prefix("https://")).then_some(i)
})
}
/// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs
/// are treated as links, each ending at the first whitespace, with trailing prose
/// punctuation peeled back into the following text. Concatenating every segment's
/// inner string reproduces the input exactly (no characters added or dropped), so
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable.
pub fn linkify(input: &str) -> Vec<Segment> {
/// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
/// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
/// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
/// username/password syntax (`http://user@host` reads as a credential but is a
/// classic destination-spoof — such text stays plain, never clickable).
pub fn is_safe_web_url(s: &str) -> bool {
let Ok(u) = url::Url::parse(s) else {
return false;
};
matches!(u.scheme(), "http" | "https")
&& u.host_str().is_some_and(|h| !h.is_empty())
&& u.username().is_empty()
&& u.password().is_none()
}
/// Detect clickable links in an (already chat-sanitized) message, returning the
/// byte range of each — computed ONCE when a message enters history and cached
/// on its entry, so redraws slice instead of rescanning. **Conservative on
/// purpose:** only `http://` / `https://` runs count, each ending at the first
/// whitespace with trailing prose punctuation peeled off, and only candidates
/// passing [`is_safe_web_url`] become links — a failing candidate's whole
/// whitespace-delimited run stays plain text (its interior is not re-scanned).
/// At most [`CHAT_MSG_MAX_LINKS`] ranges; ranges are ascending, non-overlapping,
/// and always on char boundaries. The href is exactly the displayed slice, so
/// what the user sees IS what the opener receives.
pub fn link_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut rest = input;
while !rest.is_empty() {
let Some(start) = find_scheme(rest) else {
out.push(Segment::Text(rest.to_string()));
let mut base = 0usize;
while out.len() < CHAT_MSG_MAX_LINKS {
let Some(start) = find_scheme(&text[base..]) else {
break;
};
if start > 0 {
out.push(Segment::Text(rest[..start].to_string()));
let run_start = base + start;
let run = &text[run_start..];
let run_end = run.find(char::is_whitespace).unwrap_or(run.len());
// Peel trailing punctuation back out of the candidate; a run is at least
// the 7-byte scheme long, so `base` always advances.
let candidate = run[..run_end].trim_end_matches(is_url_trailing_punct);
if is_safe_web_url(candidate) {
out.push(run_start..run_start + candidate.len());
base = run_start + candidate.len();
} else {
base = run_start + run_end;
}
let after = &rest[start..];
let end = after.find(char::is_whitespace).unwrap_or(after.len());
let candidate = &after[..end];
// Peel trailing punctuation back out of the link.
let url = candidate.trim_end_matches(is_url_trailing_punct);
out.push(Segment::Link(url.to_string()));
// Continue past just the URL; any peeled punctuation + the rest (incl. the
// whitespace) is reconsidered as ordinary text on the next iteration.
rest = &after[url.len()..];
}
out
}
/// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
/// output. Concatenating the slices reproduces `text` exactly (purely a
/// presentational split — no characters added or dropped). Borrows, so a redraw
/// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
/// this same `text` (ascending, non-overlapping, char-boundary ranges).
pub fn segments<'a>(text: &'a str, ranges: &[std::ops::Range<usize>]) -> Vec<(&'a str, bool)> {
let mut out = Vec::new();
let mut pos = 0usize;
for r in ranges {
if r.start > pos {
out.push((&text[pos..r.start], false));
}
out.push((&text[r.clone()], true));
pos = r.end;
}
if pos < text.len() {
out.push((&text[pos..], false));
}
out
}
@@ -215,93 +353,199 @@ mod tests {
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
}
// --- linkify -----------------------------------------------------------
// --- chat body policy ---------------------------------------------------
/// Concatenating every segment's inner text must reproduce the input exactly.
fn reassemble(segs: &[Segment]) -> String {
segs.iter()
.map(|s| match s {
Segment::Text(t) | Segment::Link(t) => t.as_str(),
})
#[test]
fn chat_keeps_ordinary_text_and_unicode() {
assert_eq!(sanitize_chat("hello world"), "hello world");
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
// Bodies keep format characters that label sanitizers strip: a ZWJ emoji
// family sequence survives intact.
let family = "👨\u{200D}👩\u{200D}👧";
assert_eq!(sanitize_chat(family), family);
}
#[test]
fn chat_strips_control_chars_and_collapses_whitespace() {
assert_eq!(sanitize_chat(" hi there "), "hi there");
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
assert_eq!(sanitize_chat(""), "");
}
#[test]
fn chat_caps_chars_at_exact_boundary_without_trailing_space() {
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
assert_eq!(
sanitize_chat(&"x".repeat(CHAT_MSG_MAX_CHARS))
.chars()
.count(),
CHAT_MSG_MAX_CHARS
);
// Truncation never leaves a dangling separator: with "word " units the
// cut lands mid-run, and the output still ends on visible text.
let words = "word ".repeat(1000);
let out = sanitize_chat(&words);
assert!(out.chars().count() <= CHAT_MSG_MAX_CHARS);
assert!(!out.ends_with(' '));
}
#[test]
fn chat_ceilings_never_split_a_scalar() {
// Four-byte scalars: the char cap bites first (2,000 × 4 = 8,000 bytes,
// inside the byte ceiling by design) and the last emoji is kept whole.
let emoji = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
let out = sanitize_chat(&emoji);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
// Three-byte scalars at the char boundary.
let cjk = "".repeat(CHAT_MSG_MAX_CHARS + 1);
let out = sanitize_chat(&cjk);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.is_char_boundary(out.len()));
}
#[test]
fn chat_sanitize_is_idempotent() {
for input in [
"plain text",
" spaced \t out\r\n text ",
"unicode 🎙 世界 👨\u{200D}👩\u{200D}👧",
&"word ".repeat(1000),
&"🎮".repeat(CHAT_MSG_MAX_CHARS + 100),
] {
let once = sanitize_chat(input);
assert_eq!(sanitize_chat(&once), once, "not idempotent for {input:?}");
}
}
#[test]
fn cap_chat_input_preserves_whitespace_within_bounds() {
// In-bounds input comes back byte-identical — no normalization while
// the user is still editing.
let draft = " hello world \t ".to_string();
assert_eq!(cap_chat_input(draft.clone()), draft);
}
#[test]
fn cap_chat_input_truncates_oversized_paste_on_scalar_boundary() {
let paste = "x".repeat(CHAT_MSG_MAX_CHARS + 5000);
let out = cap_chat_input(paste);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
let emoji_paste = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
let out = cap_chat_input(emoji_paste);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
}
#[test]
fn admit_rejects_oversized_raw_bytes_before_sanitizing() {
// One byte over the ceiling → rejected outright, attachment or not.
let over = "x".repeat(CHAT_MSG_MAX_BYTES + 1);
assert_eq!(admit_chat_text(&over, false), None);
assert_eq!(admit_chat_text(&over, true), None);
// Exactly at the ceiling → admitted (then sanitized/capped).
let at = "x".repeat(CHAT_MSG_MAX_BYTES);
let admitted = admit_chat_text(&at, false).expect("at-ceiling text admitted");
assert_eq!(admitted.chars().count(), CHAT_MSG_MAX_CHARS);
// Multibyte raw over the ceiling → rejected.
let cjk_over = "".repeat(CHAT_MSG_MAX_BYTES / 3 + 1);
assert!(cjk_over.len() > CHAT_MSG_MAX_BYTES);
assert_eq!(admit_chat_text(&cjk_over, false), None);
}
#[test]
fn admit_keeps_attachment_only_messages_and_drops_truly_empty_ones() {
// No visible text + no attachment → nothing to show, dropped.
assert_eq!(admit_chat_text("", false), None);
assert_eq!(admit_chat_text("\u{0}\r\n\t ", false), None);
// Same bodies WITH an attachment → kept as an empty caption.
assert_eq!(admit_chat_text("", true), Some(String::new()));
assert_eq!(admit_chat_text("\u{0}\r\n\t ", true), Some(String::new()));
// Normal text converges on the same result as direct sanitization.
assert_eq!(
admit_chat_text(" hi there ", false),
Some(sanitize_chat(" hi there "))
);
}
#[test]
fn chat_strips_bidi_overrides_but_keeps_benign_format_chars() {
// Overrides and isolates are removed outright (S14) …
assert_eq!(sanitize_chat("pay \u{202E}gpj.exe now"), "pay gpj.exe now");
assert_eq!(sanitize_chat("a\u{2066}b\u{2069}c"), "abc");
assert_eq!(
sanitize_chat("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
""
);
// … while the expressive format characters chat promises to keep — ZWJ
// (emoji sequences), ZWNJ (joining scripts), LRM/RLM (bidi *marks*, which
// cannot reorder text) — survive.
for kept in ['\u{200D}', '\u{200C}', '\u{200E}', '\u{200F}'] {
let msg = format!("a{kept}b");
assert_eq!(sanitize_chat(&msg), msg, "stripped benign {kept:?}");
}
}
// --- link policy ---------------------------------------------------------
/// Concatenating every segment's slice must reproduce the input exactly.
fn reassemble(text: &str) -> String {
segments(text, &link_ranges(text))
.iter()
.map(|(s, _)| *s)
.collect()
}
/// The link slices of a message, in order.
fn links(text: &str) -> Vec<&str> {
segments(text, &link_ranges(text))
.into_iter()
.filter_map(|(s, is_link)| is_link.then_some(s))
.collect()
}
#[test]
fn linkify_plain_text_has_no_links() {
let segs = linkify("just a normal message, nothing here");
assert_eq!(
segs,
vec![Segment::Text("just a normal message, nothing here".into())]
);
fn url_policy_accepts_only_wellformed_web_urls() {
for ok in [
"http://example.com",
"https://a.test/path?q=1&w=2",
"HTTP://EXAMPLE.COM", // mixed case scheme+host
"https://x.com:8443/p", // explicit port
"https://d.com/路径?q=世界#frag", // unicode path/query/fragment
// WHATWG parsing (what browsers do) collapses the extra slash into
// host "path" — a valid, if odd, destination; not an empty host.
"http:///path",
] {
assert!(is_safe_web_url(ok), "rejected {ok:?}");
}
for bad in [
"",
"example.com", // no scheme
"http://", // empty host
"ftp://x.com", // non-web scheme
"file:///etc/passwd", // no host, wrong scheme
"javascript:alert(1)", // opener must never see this
"http://user@good.com", // userinfo → destination spoof risk
"http://user:pw@good.com", // credentials
"http://exa mple.com", // malformed host
] {
assert!(!is_safe_web_url(bad), "accepted {bad:?}");
}
}
#[test]
fn linkify_detects_http_and_https() {
fn link_ranges_detects_http_and_https_with_exact_roundtrip() {
assert_eq!(links("see http://example.com now"), ["http://example.com"]);
assert_eq!(
linkify("see http://example.com now"),
vec![
Segment::Text("see ".into()),
Segment::Link("http://example.com".into()),
Segment::Text(" now".into()),
]
links("a http://one.com b https://two.com c"),
["http://one.com", "https://two.com"]
);
assert_eq!(
linkify("https://a.test/path?q=1"),
vec![Segment::Link("https://a.test/path?q=1".into())]
);
}
#[test]
fn linkify_peels_trailing_punctuation() {
// Sentence-final period is not part of the link.
assert_eq!(
linkify("go to https://x.com."),
vec![
Segment::Text("go to ".into()),
Segment::Link("https://x.com".into()),
Segment::Text(".".into()),
]
);
// Parenthesized URL.
assert_eq!(
linkify("(https://x.com)"),
vec![
Segment::Text("(".into()),
Segment::Link("https://x.com".into()),
Segment::Text(")".into()),
]
);
}
#[test]
fn linkify_handles_multiple_urls() {
let segs = linkify("a http://one.com b https://two.com c");
assert_eq!(
segs,
vec![
Segment::Text("a ".into()),
Segment::Link("http://one.com".into()),
Segment::Text(" b ".into()),
Segment::Link("https://two.com".into()),
Segment::Text(" c".into()),
]
);
}
#[test]
fn linkify_only_matches_http_schemes() {
// Non-web schemes and bare domains are NOT linkified (conservative).
let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
assert_eq!(
segs,
vec![Segment::Text(
"email me@x.com or ftp://x.com or visit x.com".into()
)]
);
}
#[test]
fn linkify_preserves_input_exactly() {
// Sentence-capitalized scheme still detected; href = the displayed slice.
assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
for msg in [
"",
"no urls at all",
@@ -309,12 +553,67 @@ mod tests {
"pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
"weird))) http://c.com]]] tail",
"unicode 世界 http://d.com/路径 more 世界",
"bad http:// and http://user@x.com around https://ok.org here",
] {
assert_eq!(
reassemble(&linkify(msg)),
msg,
"roundtrip failed for {msg:?}"
);
assert_eq!(reassemble(msg), msg, "roundtrip failed for {msg:?}");
}
}
#[test]
fn link_ranges_peels_trailing_punctuation() {
assert_eq!(links("go to https://x.com."), ["https://x.com"]);
assert_eq!(links("(https://x.com)"), ["https://x.com"]);
}
#[test]
fn link_ranges_leaves_invalid_candidates_as_plain_text() {
// Non-web schemes and bare domains never linkify (conservative).
assert_eq!(
links("email me@x.com or ftp://x.com or visit x.com"),
[] as [&str; 0]
);
// A malformed/deceptive candidate stays text WITHOUT eating a later
// valid link.
assert_eq!(links("http:// then https://ok.org"), ["https://ok.org"]);
assert_eq!(
links("http://user:pw@evil.com vs https://good.com"),
["https://good.com"]
);
// An invalid run's interior is not re-scanned for nested schemes.
assert_eq!(links("http://a@http://b.com"), [] as [&str; 0]);
}
#[test]
fn link_ranges_caps_clickable_links_per_message() {
let many = (0..CHAT_MSG_MAX_LINKS + 4)
.map(|i| format!("https://site{i}.test"))
.collect::<Vec<_>>()
.join(" ");
let ranges = link_ranges(&many);
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
// The 9th+ URLs remain, but as plain selectable text.
assert_eq!(reassemble(&many), many);
let l = links(&many);
assert_eq!(l.last(), Some(&"https://site7.test"));
// Exactly at the cap: all clickable.
let at_cap = (0..CHAT_MSG_MAX_LINKS)
.map(|i| format!("https://site{i}.test"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(link_ranges(&at_cap).len(), CHAT_MSG_MAX_LINKS);
}
#[test]
fn link_ranges_survives_adversarial_many_link_input() {
// A ceiling-length message packed with minimal URLs: bounded output,
// exact reconstruction, and every range on char boundaries.
let flood = "http://a.io ".repeat(CHAT_MSG_MAX_BYTES / 12 + 1);
let msg = sanitize_chat(&flood);
let ranges = link_ranges(&msg);
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
for r in &ranges {
assert!(msg.is_char_boundary(r.start) && msg.is_char_boundary(r.end));
}
assert_eq!(reassemble(&msg), msg);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! Live-edge catch-up for the screen-share viewer.
//!
//! PixelPass carries the share as MPEG-TS over a reliable, ordered transport. On
//! a lossy link (satellite handovers are the pathological case) every loss burst
//! becomes retransmission plus head-of-line blocking, and the viewer absorbs the
//! stall as buffered latency. Nothing in the chain ever trims that buffer back,
//! so the picture ends up seconds behind the host and stays there.
//!
//! Measured on a `tc netem` rig that simulates a satellite link (40 ms +/- 20 ms
//! jitter, 0.5% loss, a 250 ms/30%-loss handover burst every 15 s): a viewer with
//! ordinary timestamp pacing settles ~1.24 s behind. mpv's `--untimed` does NOT
//! help (~1.38 s, marginally worse) because it only removes pacing at
//! *presentation* while audio still drains at 1x the DAC rate, so an accumulated
//! buffer never shrinks. Returning to the live edge requires consuming the
//! backlog faster than it arrives.
//!
//! So we nudge playback slightly faster than realtime while the buffer is deep,
//! and drop back to 1x once it has drained. mpv's default pitch correction
//! (`scaletempo2`) keeps a 5% speedup inaudible, and because audio and video are
//! sped up together A/V sync is preserved — unlike `--untimed`.
//!
//! The control law and the JSON-IPC message handling are pure functions with
//! tests; the only I/O is [`drive`], which talks to mpv's `--input-ipc-server`
//! socket.
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Buffer depth (seconds) above which we start draining.
pub const CACHE_HIGH_S: f64 = 1.0;
/// Buffer depth (seconds) below which we return to realtime.
pub const CACHE_LOW_S: f64 = 0.4;
/// The buffer depth we aim to sit at; the drain rate is proportional to how far
/// above this the buffer actually is.
pub const CACHE_TARGET_S: f64 = 0.5;
/// Extra playback rate per second of excess buffer.
pub const CATCHUP_GAIN: f64 = 0.05;
/// Hard ceiling on the drain rate. Beyond this the speedup stops being
/// unnoticeable, and a share that far behind is better served by the operator
/// restarting it than by a chipmunk impression.
pub const MAX_CATCHUP_SPEED: f64 = 1.15;
/// Normal realtime playback.
pub const NORMAL_SPEED: f64 = 1.0;
/// How often we sample the buffer depth.
pub const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Smallest rate change worth sending to the player.
pub const SPEED_EPSILON: f64 = 0.005;
/// The property we watch on the viewer.
const CACHE_PROPERTY: &str = "demuxer-cache-duration";
/// Decide the playback rate for the next interval.
///
/// Proportional, because a fixed small speedup cannot recover a large backlog in
/// any reasonable time: draining 6 s at 1.05x takes two minutes, which a viewer
/// experiences as "still broken". The drain rate instead scales with how deep
/// the buffer is, so a bad handover is cleared in tens of seconds while a small
/// excursion still gets only a gentle, inaudible nudge.
///
/// Deliberately hysteretic: between [`CACHE_LOW_S`] and [`CACHE_HIGH_S`] the
/// current rate is held, so a buffer hovering near a single threshold cannot
/// oscillate the speed (and with it the audio pitch) every poll. Pure.
///
/// A non-finite reading (mpv reports `null` before playback starts, and the
/// caller maps that to NaN) holds the current rate rather than guessing.
pub fn catchup_speed(cache_s: f64, current: f64) -> f64 {
if !cache_s.is_finite() {
return current;
}
if cache_s < CACHE_LOW_S {
return NORMAL_SPEED;
}
if cache_s <= CACHE_HIGH_S {
return current;
}
let excess = cache_s - CACHE_TARGET_S;
(NORMAL_SPEED + CATCHUP_GAIN * excess).clamp(NORMAL_SPEED, MAX_CATCHUP_SPEED)
}
/// Where mpv should create its IPC socket. Kept separate from the runtime
/// lookup so tests can pin a directory. Pure.
pub fn socket_path(dir: &Path, token: u64) -> PathBuf {
dir.join(format!("peerspeak-mpv-{token}.sock"))
}
/// The directory for the IPC socket: the XDG runtime dir when the session
/// provides one (tmpfs, user-private, cleaned at logout), else the temp dir.
pub fn socket_dir() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}
/// A `get_property` request for the buffer depth. Pure.
pub fn get_cache_request(request_id: u64) -> String {
format!(r#"{{"command":["get_property","{CACHE_PROPERTY}"],"request_id":{request_id}}}"#)
}
/// A `set_property` request for the playback rate. Pure.
pub fn set_speed_request(request_id: u64, speed: f64) -> String {
format!(r#"{{"command":["set_property","speed",{speed}],"request_id":{request_id}}}"#)
}
/// Extract the buffer depth from one line of mpv's IPC output.
///
/// mpv interleaves unsolicited event lines with command replies, so a line is
/// only ours when it carries the matching `request_id`. Returns:
/// - `Some(Some(secs))` — our reply, with a usable number,
/// - `Some(None)` — our reply, but no number (mpv sends `"data":null` before
/// playback starts, and reports `error` while the demuxer has no cache yet),
/// - `None` — not our reply (an event, or another command's response).
///
/// Pure.
pub fn parse_cache_response(line: &str, request_id: u64) -> Option<Option<f64>> {
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
let id = value.get("request_id")?.as_u64()?;
if id != request_id {
return None;
}
if value.get("error").and_then(|e| e.as_str()) != Some("success") {
return Some(None);
}
Some(value.get("data").and_then(|d| d.as_f64()))
}
/// Drive one mpv viewer's playback rate over its JSON IPC socket.
///
/// Runs until mpv exits (the socket dies), so it is spawned detached alongside
/// the player and needs no shutdown signal. Every failure path just ends the
/// task: catch-up is an optimization, and a viewer that never gets it still
/// plays, exactly as before this existed.
#[cfg(unix)]
pub async fn drive(socket: PathBuf) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
// mpv creates the socket a moment after exec, so the first connects race it.
let mut stream = None;
for _ in 0..40 {
match UnixStream::connect(&socket).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
}
}
let Some(stream) = stream else {
crate::log_msg("livesync: mpv IPC socket never appeared; catch-up disabled");
return;
};
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
let mut request_id: u64 = 0;
let mut speed = NORMAL_SPEED;
loop {
tokio::time::sleep(POLL_INTERVAL).await;
request_id += 1;
let query = format!("{}\n", get_cache_request(request_id));
if write_half.write_all(query.as_bytes()).await.is_err() {
break;
}
// Skip event lines until our reply arrives.
let cache = loop {
match lines.next_line().await {
Ok(Some(line)) => {
if let Some(value) = parse_cache_response(&line, request_id) {
break value;
}
}
// Socket closed or unreadable: mpv is gone.
_ => return,
}
};
let cache = cache.unwrap_or(f64::NAN);
let next = catchup_speed(cache, speed);
// A proportional law would otherwise re-send on every wobble of the
// reading; only a change worth hearing is worth a round trip.
if (next - speed).abs() > SPEED_EPSILON {
speed = next;
request_id += 1;
let set = format!("{}\n", set_speed_request(request_id, speed));
if write_half.write_all(set.as_bytes()).await.is_err() {
break;
}
crate::log_msg(&format!(
"livesync: cache {cache:.2}s -> playback speed {speed}x"
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deep_buffer_speeds_up_and_drained_buffer_returns_to_realtime() {
assert!(catchup_speed(1.5, NORMAL_SPEED) > NORMAL_SPEED);
assert_eq!(catchup_speed(0.1, MAX_CATCHUP_SPEED), NORMAL_SPEED);
}
#[test]
fn drain_rate_scales_with_how_far_behind_we_are() {
// The point of the proportional law: a small excursion gets a gentle
// nudge, a deep backlog gets real recovery.
let small = catchup_speed(1.5, NORMAL_SPEED);
let large = catchup_speed(4.0, NORMAL_SPEED);
assert!(
large > small,
"deeper buffer must drain faster: {small} vs {large}"
);
assert!(
(small - 1.05).abs() < 1e-9,
"1.5s buffer -> 1.05x, got {small}"
);
}
#[test]
fn drain_rate_is_capped_so_it_never_sounds_absurd() {
// The ~6 s standing buffer measured on the netem rig, and far worse.
assert_eq!(catchup_speed(6.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
assert_eq!(catchup_speed(600.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
}
#[test]
fn hysteresis_band_holds_the_current_speed() {
// Between the marks nothing changes, whichever side we came from —
// this is what stops the rate (and audio pitch) oscillating.
for cache in [CACHE_LOW_S, 0.7, CACHE_HIGH_S] {
assert_eq!(catchup_speed(cache, NORMAL_SPEED), NORMAL_SPEED);
assert_eq!(catchup_speed(cache, MAX_CATCHUP_SPEED), MAX_CATCHUP_SPEED);
}
}
#[test]
fn unknown_cache_holds_the_current_speed() {
assert_eq!(
catchup_speed(f64::NAN, MAX_CATCHUP_SPEED),
MAX_CATCHUP_SPEED
);
assert_eq!(catchup_speed(f64::INFINITY, NORMAL_SPEED), NORMAL_SPEED);
}
#[test]
fn a_full_handover_cycle_drains_then_settles() {
// Buffer grows through a loss burst, then drains as we play faster.
let mut speed = NORMAL_SPEED;
for cache in [0.2, 0.5, 1.2, 3.4, 1.4, 0.9, 0.6, 0.3, 0.2] {
speed = catchup_speed(cache, speed);
}
assert_eq!(
speed, NORMAL_SPEED,
"should be back at realtime once drained"
);
}
#[test]
fn requests_are_valid_json_with_their_ids() {
let get: serde_json::Value = serde_json::from_str(&get_cache_request(7)).unwrap();
assert_eq!(get["request_id"], 7);
assert_eq!(get["command"][0], "get_property");
assert_eq!(get["command"][1], CACHE_PROPERTY);
let set: serde_json::Value = serde_json::from_str(&set_speed_request(8, 1.05)).unwrap();
assert_eq!(set["request_id"], 8);
assert_eq!(set["command"][0], "set_property");
assert_eq!(set["command"][1], "speed");
assert_eq!(set["command"][2], 1.05);
}
#[test]
fn parses_our_reply_only() {
assert_eq!(
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":3}"#, 3),
Some(Some(1.25))
);
// Another command's reply, and an unsolicited event, are not ours.
assert_eq!(
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":4}"#, 3),
None
);
assert_eq!(
parse_cache_response(r#"{"event":"playback-restart"}"#, 3),
None
);
assert_eq!(parse_cache_response("not json", 3), None);
}
#[test]
fn reply_without_a_usable_number_is_ours_but_empty() {
// mpv before playback starts, and while the demuxer has no cache.
assert_eq!(
parse_cache_response(r#"{"error":"success","data":null,"request_id":1}"#, 1),
Some(None)
);
assert_eq!(
parse_cache_response(r#"{"error":"property unavailable","request_id":1}"#, 1),
Some(None)
);
}
#[test]
fn socket_path_is_scoped_to_its_token() {
let a = socket_path(Path::new("/run/user/1000"), 42);
assert_eq!(a, Path::new("/run/user/1000/peerspeak-mpv-42.sock"));
assert_ne!(a, socket_path(Path::new("/run/user/1000"), 43));
}
}
+161 -23
View File
@@ -21,6 +21,8 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
pub mod livesync;
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
/// The binary we shell out to. Looked up on `$PATH` unless a config override
@@ -45,6 +47,12 @@ const MAX_TICKET_LEN: usize = 512;
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256;
/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
/// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -374,7 +382,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())
@@ -603,16 +615,28 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The flags keep latency low while preserving A/V sync. We deliberately do
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
/// progressively out of sync with its audio. Pacing to the audio clock costs a
/// little latency (negligible for pointing at a desktop) and keeps a shared
/// video in sync. We also leave hwdec at the `low-latency` default (software
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
/// kept playing.
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
/// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
/// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
let mpv_args = mpv_args(settings);
// One socket per viewer launch, so overlapping shares can't collide on it.
// Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
// speak, and an unusable socket path on the argv would help nobody.
#[cfg(unix)]
let ipc_socket = Some(livesync::socket_path(
&livesync::socket_dir(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0),
));
#[cfg(not(unix))]
let ipc_socket: Option<PathBuf> = None;
let mpv_args = mpv_args(settings, ipc_socket.as_deref());
let vlc_args = vlc_args(settings);
let first = match settings.player {
SharePlayer::Mpv => ("mpv", &mpv_args),
@@ -623,15 +647,32 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
SharePlayer::Vlc => ("mpv", &mpv_args),
};
let child = match spawn_player(first.0, first.1, url) {
Ok(c) => c,
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
let (launched, child) = match spawn_player(first.0, first.1, url) {
Ok(c) => (first.0, c),
Err(_) => (
second.0,
spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
),
};
// Only when the socket actually reached the argv: mpv (VLC has no
// equivalent IPC) in the Low latency posture. The driver ends by itself when
// the player exits, so it needs no shutdown path.
#[cfg(unix)]
if launched == "mpv"
&& settings.buffering == ShareBuffering::LowLatency
&& let Some(socket) = ipc_socket
{
tokio::spawn(livesync::drive(socket));
}
#[cfg(not(unix))]
let _ = launched;
tokio::spawn(async move {
let mut child = child;
let _ = child.wait().await;
@@ -639,11 +680,23 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
Ok(())
}
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
/// Build the argv for an mpv viewer.
///
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
/// fight on every poll.
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
let mut args = Vec::new();
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
// lossy link turns every retransmission into buffered latency that
// nothing trims back. `--untimed` does NOT fix that (measured
// marginally worse: it only unpaces *presentation*, while audio
// still drains at 1x, so the backlog never shrinks) — the viewer
// instead drains it by playing slightly fast, see `livesync`.
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
@@ -652,10 +705,25 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
args.push("--demuxer-readahead-secs=2".to_string());
}
}
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
// The byte cap is what bounds how far behind a viewer can silently fall:
// a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
// measured on the netem rig) and call it a buffer. Low latency therefore
// gets a tighter ceiling than the user's Smooth-oriented setting, so the
// catch-up has less to claw back after a bad patch of link.
let cache_mb = match settings.buffering {
ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
ShareBuffering::Smooth => settings.cache_mb,
};
args.push(format!("--demuxer-max-bytes={cache_mb}M"));
if settings.hardware_decode {
args.push("--hwdec=auto".to_string());
}
if let Some(socket) = ipc_socket
&& settings.buffering == ShareBuffering::LowLatency
{
args.push(format!("--input-ipc-server={}", socket.display()));
}
// Extra args stay last so a user override wins over everything above.
args.extend(split_extra_args(&settings.extra_mpv_args));
args
}
@@ -821,16 +889,86 @@ mod tests {
#[test]
fn mpv_args_default_matches_low_latency_software_decode() {
assert_eq!(
mpv_args(&ScreenShareSettings::default()),
mpv_args(&ScreenShareSettings::default(), None),
vec![
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=2M",
"--demuxer-max-bytes=1M",
]
);
}
#[test]
fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
let args = mpv_args(
&ScreenShareSettings::default(),
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
);
assert!(
args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
"low latency drains a lagging buffer over mpv IPC: {args:?}"
);
// The flag that used to hold this posture at the live edge measured no
// better than pacing, and cost A/V sync — it must not come back.
assert!(!args.contains(&"--untimed".to_string()));
}
#[test]
fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
let settings = ScreenShareSettings {
buffering: ShareBuffering::Smooth,
..ScreenShareSettings::default()
};
let args = mpv_args(
&settings,
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
);
assert!(
!args.iter().any(|a| a.starts_with("--input-ipc-server")),
"catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
);
}
#[test]
fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
// The cache is a byte budget, so at a given bitrate it sets the
// worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
let generous = ScreenShareSettings {
cache_mb: 32,
..ScreenShareSettings::default()
};
assert!(
mpv_args(&generous, None)
.contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
"low latency must bound how far behind the viewer can silently fall"
);
let smooth = ScreenShareSettings {
cache_mb: 32,
buffering: ShareBuffering::Smooth,
..ScreenShareSettings::default()
};
assert!(
mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
"smooth is the posture where the user asked for a deep buffer"
);
}
#[test]
fn user_extra_args_still_come_last() {
let settings = ScreenShareSettings {
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
assert_eq!(
args.last().map(String::as_str),
Some("--no-osc"),
"a user override has to win over everything we add: {args:?}"
);
}
#[test]
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
let settings = ScreenShareSettings {
@@ -842,7 +980,7 @@ mod tests {
};
assert_eq!(
mpv_args(&settings),
mpv_args(&settings, None),
vec![
"--cache=yes",
"--demuxer-readahead-secs=2",
+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);
+1 -1
View File
@@ -21,7 +21,7 @@ const HIT_SEARCH_STEPS: usize = 24;
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
// offset WITHIN its buffer line — it discards the line number. That equals the
// global byte offset only when the text is a single logical line. Chat bodies
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
// satisfy this because `sanitize::sanitize_chat` turns every control char (incl. `\n`
// and `\r`) into a space and collapses whitespace, so a stored message can never
// contain a newline. If that sanitizer ever starts preserving newlines, this
// widget's per-line offsets would stop being global and selection/copy across
+36
View File
@@ -201,3 +201,39 @@ async fn rejoin_after_grace_eviction_dials_cleanly() {
"an initial dial after a grace eviction must not be treated as a reconnect"
);
}
/// Chat-hardening Phase 2: a peer mid-reconnect-grace keeps its roster-bound
/// chat name (its chat stays admitted), but a TERMINAL grace-expiry eviction
/// revokes it — after that, only a fresh authenticated Announce (PeerJoined)
/// restores chat authority.
#[tokio::test]
async fn grace_eviction_revokes_chat_roster_entry() {
use peerspeak::core::chatroster::ChatRoster;
let (ui_tx, mut ui_rx) = mpsc::channel(100);
let roster = ChatRoster::default();
let h = make_handler(ui_tx, make_transport().await, GRACE).with_chat_roster(roster.clone());
let peer = fake_peer();
roster.upsert(peer, "Victim");
// Link up, then drop: DURING the grace window the peer is still a member —
// its chat must keep rendering under its roster name.
h.handle(ConnEvent::Connected(peer)).await;
h.handle(ConnEvent::Connecting(peer)).await;
assert_eq!(
roster.name_of(&peer),
Some("Victim".to_string()),
"reconnect grace must NOT revoke chat authority"
);
// Once the grace expires and the eviction fires, chat authority goes too.
assert!(
evicted_within(&mut ui_rx, peer, GRACE * 4).await,
"the outage should evict once the grace window elapses"
);
assert_eq!(
roster.name_of(&peer),
None,
"terminal eviction must revoke the roster-bound chat name"
);
}
+94 -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"
);
}
@@ -568,3 +570,94 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
"audio should resume after reconnecting via the retained address; got {received} frames"
);
}
/// A node that also serves the file plane (`FILES_ALPN`), mirroring how core
/// registers the `FileRouter` for a session.
async fn spawn_file_server() -> Node {
let lookup = MemoryLookup::new();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(iroh::SecretKey::generate())
.relay_mode(RelayMode::Disabled)
.address_lookup(lookup.clone())
.bind()
.await
.expect("bind endpoint");
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
let audio_router = AudioRouter::new();
audio_router.bind(&transport);
let file_router = peerspeak::network::iroh_impl::FileRouter::new();
file_router.bind(&transport);
let router = Router::builder(endpoint.clone())
.accept(AUDIO_ALPN, audio_router)
.accept(peerspeak::protocol::FILES_ALPN, file_router)
.spawn();
Node {
endpoint,
transport,
_router: router,
lookup,
}
}
/// Phase 3C: a file fetch must deliver EXACTLY the declared size — short,
/// overlong, and unknown-id transfers are all rejected with local errors, and
/// an exact transfer round-trips byte-identically.
#[tokio::test]
async fn file_plane_requires_exact_declared_size() {
let fetcher = spawn_node().await;
let server = spawn_file_server().await;
fetcher.lookup.add_endpoint_info(server.endpoint.addr());
server.lookup.add_endpoint_info(fetcher.endpoint.addr());
let server_id = server.endpoint.id();
// Member gating: the server only serves current room members.
server.transport.admit_audio_sender(fetcher.endpoint.id());
let blob = vec![42u8; 1000];
let id = [7u8; 32];
server
.transport
.serve_attachment(id, Arc::new(blob.clone()));
// Exact declared size: byte-identical round trip.
let got = fetcher
.transport
.fetch_blob(server_id, id, 1000)
.await
.expect("exact-size fetch succeeds");
assert_eq!(got, blob);
// Declared larger than served (short transfer): rejected, not cached as-is.
let err = fetcher
.transport
.fetch_blob(server_id, id, 2000)
.await
.expect_err("short transfer must fail");
assert!(
err.to_string().contains("incomplete transfer"),
"unexpected error: {err}"
);
// Declared smaller than served (overlong transfer): the bounded read
// rejects the stream rather than truncating it into a "valid" result.
let err = fetcher
.transport
.fetch_blob(server_id, id, 500)
.await
.expect_err("overlong transfer must fail");
assert!(
err.to_string().contains("read failed"),
"unexpected error: {err}"
);
// Unknown id: the empty body reads as the sender no longer having it.
let err = fetcher
.transport
.fetch_blob(server_id, [9u8; 32], 1000)
.await
.expect_err("unknown id must fail");
assert!(
err.to_string().contains("no longer has the file"),
"unexpected error: {err}"
);
}