Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c4f68bb3 | ||
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de | ||
|
|
3d7b01c8a2 | ||
|
|
5f4eba1815 | ||
|
|
77d2bf2992 | ||
|
|
c8d0053431 | ||
|
|
1d038be9a0 | ||
|
|
554b613466 | ||
|
|
8898652349 | ||
|
|
5927148ee4 | ||
|
|
93f4954653 | ||
|
|
e6eb490939 | ||
|
|
e724167b03 | ||
|
|
7b9cb57003 | ||
|
|
af7a42a049 | ||
|
|
e78e7bc2a5 | ||
|
|
52ab374b74 | ||
|
|
8825707c17 | ||
|
|
76c62e5ac3 | ||
|
|
d2432740c1 | ||
|
|
99a4a336ad | ||
|
|
df45c0bfeb | ||
|
|
faad8ce26a | ||
|
|
e378b2e33b | ||
|
|
96e41de1b1 | ||
|
|
5c888f8357 | ||
|
|
074f004227 | ||
|
|
2d22036930 |
@@ -0,0 +1,11 @@
|
|||||||
|
# cargo-audit configuration. Keep the ignore list in sync with deny.toml,
|
||||||
|
# which carries the full justification for each entry.
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
# quick-xml DoS advisories: build-time only, reached solely via the
|
||||||
|
# wayland-scanner proc-macro parsing trusted vendored protocol XML.
|
||||||
|
# Fix (0.41.0) is semver-incompatible with wayland-scanner's `^0.39`;
|
||||||
|
# drop once wayland-scanner bumps. See deny.toml.
|
||||||
|
"RUSTSEC-2026-0194",
|
||||||
|
"RUSTSEC-2026-0195",
|
||||||
|
]
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
name: cargo-deny
|
|
||||||
|
|
||||||
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
|
|
||||||
# sources) on every push to main and every PR. Runs on a *locked* tree so the
|
|
||||||
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
|
|
||||||
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
|
|
||||||
# cannot reach CI until Cargo.lock is deliberately updated.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cargo-deny:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
|
|
||||||
# `cargo metadata`. Adjust the runner label if your act_runner uses a
|
|
||||||
# different one.
|
|
||||||
container: rust:1
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install cargo-deny (pinned prebuilt)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version=0.19.9
|
|
||||||
curl -sSfL \
|
|
||||||
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
|
|
||||||
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
|
|
||||||
cargo-deny --version
|
|
||||||
|
|
||||||
- name: cargo deny check
|
|
||||||
run: cargo deny --locked check
|
|
||||||
@@ -36,7 +36,9 @@ jobs:
|
|||||||
run: cargo test --doc
|
run: cargo test --doc
|
||||||
|
|
||||||
- name: cargo-deny (advisories, bans, licenses, sources)
|
- name: cargo-deny (advisories, bans, licenses, sources)
|
||||||
run: cargo deny check
|
# --locked so the pinned, vetted versions in Cargo.lock are exactly
|
||||||
|
# what get audited (the lockfile-as-review-checkpoint model).
|
||||||
|
run: cargo deny --locked check
|
||||||
|
|
||||||
- name: cargo-audit
|
- name: cargo-audit
|
||||||
run: cargo audit
|
run: cargo audit
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ name: windows-build
|
|||||||
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
||||||
#
|
#
|
||||||
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
||||||
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
|
# `windows-latest` label (a Linux-container approach does NOT apply here —
|
||||||
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
|
# Windows jobs run on the host, not a Linux container). If your runner
|
||||||
# runner advertises a different label, change `runs-on` below. Until a Windows
|
# advertises a different label, change `runs-on` below.
|
||||||
# runner exists this workflow is simply skipped/queued, not a failure of the
|
#
|
||||||
# Linux CI.
|
# MANUAL-ONLY until that runner exists: with push/PR triggers enabled, every
|
||||||
|
# push queued a run no runner could claim and Gitea auto-cancelled it ~24h
|
||||||
|
# later, littering the Actions page with cancelled runs. Restore the push/PR
|
||||||
|
# triggers when a Windows runner is registered:
|
||||||
|
#
|
||||||
|
# on:
|
||||||
|
# push:
|
||||||
|
# branches: [main, "windows-port-**"]
|
||||||
|
# pull_request:
|
||||||
|
# workflow_dispatch:
|
||||||
#
|
#
|
||||||
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
||||||
# peerspeak-windows-opus-spike.md):
|
# peerspeak-windows-opus-spike.md):
|
||||||
@@ -23,12 +32,7 @@ name: windows-build
|
|||||||
# must provide both.
|
# must provide both.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Manual runs from the Gitea Actions UI only — see the header comment.
|
||||||
# `main` plus the in-progress port branches, so the Windows path is exercised
|
|
||||||
# before merge rather than only after.
|
|
||||||
branches: [main, "windows-port-**"]
|
|
||||||
pull_request:
|
|
||||||
# Allow manual runs from the Gitea Actions UI.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -2,6 +2,87 @@
|
|||||||
|
|
||||||
All notable changes to PeerSpeak are documented here.
|
All notable changes to PeerSpeak are documented here.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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 7–10 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
|
||||||
|
- **In-app screen-sharing controls.** A new **Screen sharing** section in Settings, plus a per-call **quality picker** on the Share control, put the whole share pipeline under your control without editing config files. Encode side: quality preset, bitrate, framerate, maximum resolution, maximum viewers, a force-software-encode switch, and an escape hatch for extra pixelpass arguments. Playback side: choose **mpv or VLC**, toggle **hardware decoding**, pick a buffering posture (low-latency vs. smooth), set the demuxer cache, and pass extra mpv arguments. Everything is stored locally in your config and defaults are unchanged, so existing setups keep working as-is.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Shared video no longer freezes on the first frame while audio keeps playing.** Hardware decoding now defaults **off**; forcing `--hwdec=auto` stalled some viewers' hardware decoder on frame 1. You can re-enable hardware decoding from the new Screen sharing settings if your machine handles it well.
|
||||||
|
- **The per-call quality picker is now honored.** The inline quality dropdown next to the Share button was being reset to the saved default before a share started, so every share silently used the default quality regardless of what you picked.
|
||||||
|
- **VLC now respects your playback settings.** VLC hardware-decodes by default, so a VLC viewer previously ignored the hardware-decode toggle (and could hit the same frame-1 freeze) and the buffering posture. VLC viewers now map both settings onto VLC's own options.
|
||||||
|
|
||||||
|
[0.6.3]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.3
|
||||||
|
|
||||||
## [0.6.2] — 2026-07-03
|
## [0.6.2] — 2026-07-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+4
-3
@@ -1207,9 +1207,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.6.2"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4894,6 +4894,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"url",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.6.2"
|
version = "0.6.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -75,6 +75,10 @@ serde_json = "1.0.150"
|
|||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["full"] }
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
tokio-stream = "0.1.18"
|
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 -----------------------------------------
|
# --- Platform-specific dependencies -----------------------------------------
|
||||||
# Audio and the native file-picker backends differ per OS. Everything else in the
|
# 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.
@@ -76,6 +76,14 @@ CHIMES = {
|
|||||||
"mic-toggle.wav": [(E5, 0.08)],
|
"mic-toggle.wav": [(E5, 0.08)],
|
||||||
# Reconnect gave up: disappointing low two-note fall.
|
# Reconnect gave up: disappointing low two-note fall.
|
||||||
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
"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)],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ ignore = [
|
|||||||
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
||||||
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
||||||
"RUSTSEC-2026-0150",
|
"RUSTSEC-2026-0150",
|
||||||
|
# ttf-parser: unmaintained, transitive via iced/cosmic-text (font parsing
|
||||||
|
# for the GUI). Inputs are system + embedded fonts, not network data. No
|
||||||
|
# upstream migration yet; revisit when iced moves off it.
|
||||||
|
"RUSTSEC-2026-0192",
|
||||||
|
# quick-xml 0.39.4 DoS advisories (quadratic dup-attr check; unbounded
|
||||||
|
# namespace allocation). Build-time only: quick-xml is reached solely via
|
||||||
|
# the wayland-scanner PROC-MACRO, which parses the wayland protocol XML
|
||||||
|
# files vendored inside the wayland-* crates at compile time. Attacker
|
||||||
|
# input never reaches it and it is not in the shipped binary. The fix
|
||||||
|
# (0.41.0) is semver-incompatible with wayland-scanner 0.31.x's `^0.39`
|
||||||
|
# requirement; drop both ignores once wayland-scanner releases a bump.
|
||||||
|
"RUSTSEC-2026-0194",
|
||||||
|
"RUSTSEC-2026-0195",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ covers internals). When you ship a feature, add it here.
|
|||||||
| iroh QUIC transport | ✅ | |
|
| iroh QUIC transport | ✅ | |
|
||||||
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
|
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
|
||||||
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
|
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
|
||||||
|
| Per-peer connection badge (direct/relay + RTT, hover for addr/loss/bitrate) | ✅ | Peer-card badge fed by a 1 Hz poll of the live audio link's selected QUIC path (`connection_stats` → `core::connstats::derive`). Field-verified on a real 2-machine call 2026-07-08. |
|
||||||
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
|
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
|
||||||
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
|
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
# Chat hardening — ephemeral implementation plan
|
||||||
|
|
||||||
|
**Status (2026-07-18):** Phases 1–5 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+202A–202E, U+2066–2069) — 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
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.6.1.r315.ga78860d
|
pkgver=0.6.2.r319.g8014edf
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.6.2"
|
#define MyAppVersion "0.6.5"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
+1946
-231
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -168,6 +168,139 @@ impl std::fmt::Display for NetworkMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
|
||||||
|
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ShareQuality {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareQuality {
|
||||||
|
pub const ALL: [ShareQuality; 5] = [
|
||||||
|
ShareQuality::Auto,
|
||||||
|
ShareQuality::Low,
|
||||||
|
ShareQuality::Medium,
|
||||||
|
ShareQuality::High,
|
||||||
|
ShareQuality::Source,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ShareQuality {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
ShareQuality::Auto => "Auto",
|
||||||
|
ShareQuality::Low => "Low",
|
||||||
|
ShareQuality::Medium => "Medium",
|
||||||
|
ShareQuality::High => "High",
|
||||||
|
ShareQuality::Source => "Source",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preferred local player for watching a peer's screen share.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SharePlayer {
|
||||||
|
#[default]
|
||||||
|
Mpv,
|
||||||
|
Vlc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharePlayer {
|
||||||
|
pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SharePlayer {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
SharePlayer::Mpv => "mpv",
|
||||||
|
SharePlayer::Vlc => "VLC",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local player buffering posture for screen-share playback.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ShareBuffering {
|
||||||
|
#[default]
|
||||||
|
LowLatency,
|
||||||
|
Smooth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareBuffering {
|
||||||
|
pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ShareBuffering {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
ShareBuffering::LowLatency => "Low latency",
|
||||||
|
ShareBuffering::Smooth => "Smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_screen_share_cache_mb() -> u32 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only screen-share preferences. Host fields become pixelpass host CLI
|
||||||
|
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
|
||||||
|
/// deliberately let pixelpass/player defaults stand.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ScreenShareSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub quality: ShareQuality,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bitrate_mbps: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub framerate: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_height: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_viewers: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_software_encode: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_host_args: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub player: SharePlayer,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hardware_decode: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub buffering: ShareBuffering,
|
||||||
|
#[serde(default = "default_screen_share_cache_mb")]
|
||||||
|
pub cache_mb: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_mpv_args: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScreenShareSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
quality: ShareQuality::default(),
|
||||||
|
bitrate_mbps: None,
|
||||||
|
framerate: None,
|
||||||
|
max_height: None,
|
||||||
|
max_viewers: None,
|
||||||
|
force_software_encode: false,
|
||||||
|
extra_host_args: String::new(),
|
||||||
|
player: SharePlayer::default(),
|
||||||
|
hardware_decode: false,
|
||||||
|
buffering: ShareBuffering::default(),
|
||||||
|
cache_mb: default_screen_share_cache_mb(),
|
||||||
|
extra_mpv_args: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -345,6 +478,14 @@ pub struct AppConfig {
|
|||||||
pub custom_sound_mic_toggle: Option<String>,
|
pub custom_sound_mic_toggle: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_sound_reconnect_failed: Option<String>,
|
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
|
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
||||||
/// gates ALL chimes; these let the user silence individual events while the
|
/// 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.
|
/// master stays on. A chime plays only if the master AND its flag are true.
|
||||||
@@ -365,10 +506,21 @@ pub struct AppConfig {
|
|||||||
pub sound_mic_toggle_enabled: bool,
|
pub sound_mic_toggle_enabled: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub sound_reconnect_failed_enabled: bool,
|
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).
|
/// Optional override for the `pixelpass` binary location (screen share).
|
||||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pixelpass_path: Option<String>,
|
pub pixelpass_path: Option<String>,
|
||||||
|
/// Local-only host/player controls for screen sharing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub screen_share: ScreenShareSettings,
|
||||||
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
|
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
|
||||||
/// one-click rejoin; never sent over the wire. De-duped by room topic and
|
/// one-click rejoin; never sent over the wire. De-duped by room topic and
|
||||||
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
||||||
@@ -465,6 +617,10 @@ impl Default for AppConfig {
|
|||||||
custom_sound_self_leave: None,
|
custom_sound_self_leave: None,
|
||||||
custom_sound_mic_toggle: None,
|
custom_sound_mic_toggle: None,
|
||||||
custom_sound_reconnect_failed: 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_self_join_enabled: true,
|
||||||
sound_peer_join_enabled: true,
|
sound_peer_join_enabled: true,
|
||||||
sound_peer_leave_enabled: true,
|
sound_peer_leave_enabled: true,
|
||||||
@@ -473,7 +629,12 @@ impl Default for AppConfig {
|
|||||||
sound_self_leave_enabled: true,
|
sound_self_leave_enabled: true,
|
||||||
sound_mic_toggle_enabled: true,
|
sound_mic_toggle_enabled: true,
|
||||||
sound_reconnect_failed_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,
|
pixelpass_path: None,
|
||||||
|
screen_share: ScreenShareSettings::default(),
|
||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
peer_eq: HashMap::new(),
|
peer_eq: HashMap::new(),
|
||||||
peer_pan: HashMap::new(),
|
peer_pan: HashMap::new(),
|
||||||
@@ -502,6 +663,10 @@ impl AppConfig {
|
|||||||
Sound::SelfLeave => self.sound_self_leave_enabled,
|
Sound::SelfLeave => self.sound_self_leave_enabled,
|
||||||
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
||||||
Sound::ReconnectFailed => self.sound_reconnect_failed_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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +681,10 @@ impl AppConfig {
|
|||||||
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
||||||
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
||||||
Sound::ReconnectFailed => self.sound_reconnect_failed_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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +972,18 @@ mod tests {
|
|||||||
assert!(deserialized.custom_sound_self_leave.is_none());
|
assert!(deserialized.custom_sound_self_leave.is_none());
|
||||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||||
assert!(deserialized.custom_sound_reconnect_failed.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);
|
||||||
|
assert_eq!(
|
||||||
|
deserialized.screen_share.buffering,
|
||||||
|
ShareBuffering::LowLatency
|
||||||
|
);
|
||||||
|
assert_eq!(deserialized.screen_share.cache_mb, 2);
|
||||||
// Configs predating the per-sound flags (W6) enable every chime, so an
|
// Configs predating the per-sound flags (W6) enable every chime, so an
|
||||||
// upgrade is silent-change-free.
|
// upgrade is silent-change-free.
|
||||||
for sound in Sound::ALL {
|
for sound in Sound::ALL {
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! Per-peer connection-transparency derivation.
|
||||||
|
//!
|
||||||
|
//! The transport hands us cumulative counters for each peer's selected QUIC
|
||||||
|
//! path ([`PathSnapshot`]); this module turns two consecutive snapshots into
|
||||||
|
//! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path
|
||||||
|
//! type, RTT, and loss/bitrate over the poll window. Pure functions only —
|
||||||
|
//! the polling task in `core::mod` owns the clock and the previous-snapshot
|
||||||
|
//! map.
|
||||||
|
|
||||||
|
use crate::network::PathSnapshot;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// How often the core polls the transport for path snapshots.
|
||||||
|
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// Derived, display-ready connection info for one peer, sent to the UI via
|
||||||
|
/// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they
|
||||||
|
/// can't be derived yet (first poll, path switch, or an idle window).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PeerConnInfo {
|
||||||
|
/// True = relayed path, false = direct IP path.
|
||||||
|
pub relay: bool,
|
||||||
|
/// `ip:port` for a direct path, the relay URL for a relayed one.
|
||||||
|
pub remote_addr: String,
|
||||||
|
/// Path round-trip time, rounded to whole milliseconds.
|
||||||
|
pub rtt_ms: u32,
|
||||||
|
/// Percentage of packets sent in the window that were detected lost.
|
||||||
|
pub loss_pct: Option<f32>,
|
||||||
|
/// Outbound bitrate over the window, kilobits per second.
|
||||||
|
pub up_kbps: Option<f32>,
|
||||||
|
/// Inbound bitrate over the window, kilobits per second.
|
||||||
|
pub down_kbps: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive display info from the current snapshot and (when comparable) the
|
||||||
|
/// previous one. `prev` is comparable only if it's the same path — a relay→
|
||||||
|
/// direct migration or a reconnect resets the counters, so those windows
|
||||||
|
/// yield `None` rates rather than garbage (negative deltas show up as
|
||||||
|
/// `cur < prev` and are treated the same way).
|
||||||
|
pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo {
|
||||||
|
let rates = prev
|
||||||
|
.filter(|p| comparable(p, cur))
|
||||||
|
.and_then(|p| window_rates(p, cur, elapsed));
|
||||||
|
PeerConnInfo {
|
||||||
|
relay: cur.is_relay,
|
||||||
|
remote_addr: cur.remote_addr.clone(),
|
||||||
|
rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32,
|
||||||
|
loss_pct: rates.and_then(|r| r.loss_pct),
|
||||||
|
up_kbps: rates.map(|r| r.up_kbps),
|
||||||
|
down_kbps: rates.map(|r| r.down_kbps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `cur`'s counters continue `prev`'s: same path (address) and
|
||||||
|
/// monotonically non-decreasing counters (a reconnect on the same address
|
||||||
|
/// restarts them from zero).
|
||||||
|
fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool {
|
||||||
|
prev.remote_addr == cur.remote_addr
|
||||||
|
&& cur.tx_bytes >= prev.tx_bytes
|
||||||
|
&& cur.rx_bytes >= prev.rx_bytes
|
||||||
|
&& cur.tx_datagrams >= prev.tx_datagrams
|
||||||
|
&& cur.lost_packets >= prev.lost_packets
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct WindowRates {
|
||||||
|
loss_pct: Option<f32>,
|
||||||
|
up_kbps: f32,
|
||||||
|
down_kbps: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
|
||||||
|
let secs = elapsed.as_secs_f64();
|
||||||
|
if secs <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sent = cur.tx_datagrams - prev.tx_datagrams;
|
||||||
|
let lost = cur.lost_packets - prev.lost_packets;
|
||||||
|
// Loss detection lags sending (it needs ACK timeouts), so a window can see
|
||||||
|
// more losses than sends; clamp to 100% rather than exceeding it. An idle
|
||||||
|
// window (nothing sent or lost) has no loss story to tell.
|
||||||
|
let loss_pct = if sent == 0 && lost == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32)
|
||||||
|
};
|
||||||
|
let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32;
|
||||||
|
Some(WindowRates {
|
||||||
|
loss_pct,
|
||||||
|
up_kbps: kbps(cur.tx_bytes - prev.tx_bytes),
|
||||||
|
down_kbps: kbps(cur.rx_bytes - prev.rx_bytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot {
|
||||||
|
PathSnapshot {
|
||||||
|
is_relay: false,
|
||||||
|
remote_addr: addr.to_string(),
|
||||||
|
rtt: Duration::from_millis(12),
|
||||||
|
tx_bytes: tx_b,
|
||||||
|
rx_bytes: rx_b,
|
||||||
|
tx_datagrams: tx_d,
|
||||||
|
lost_packets: lost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_poll_has_type_and_rtt_but_no_rates() {
|
||||||
|
let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0);
|
||||||
|
let info = derive(None, &cur, POLL_INTERVAL);
|
||||||
|
assert_eq!(info.rtt_ms, 12);
|
||||||
|
assert!(!info.relay);
|
||||||
|
assert_eq!(info.remote_addr, "1.2.3.4:5");
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.down_kbps, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steady_window_yields_rates_and_loss() {
|
||||||
|
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||||
|
// 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost.
|
||||||
|
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.up_kbps, Some(32.0));
|
||||||
|
assert_eq!(info.down_kbps, Some(16.0));
|
||||||
|
assert_eq!(info.loss_pct, Some(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_window_has_no_loss_story() {
|
||||||
|
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||||
|
let cur = prev.clone();
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
assert_eq!(info.up_kbps, Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loss_detected_in_an_idle_window_clamps_to_full() {
|
||||||
|
// Losses can be *detected* after sending stops (ACK timeouts fire late).
|
||||||
|
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0);
|
||||||
|
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.loss_pct, Some(100.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_switch_resets_the_window() {
|
||||||
|
let prev = snap("relay.example:443", 9000, 9000, 900, 5);
|
||||||
|
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counter_reset_on_same_address_resets_the_window() {
|
||||||
|
// Same address but the connection was rebuilt → counters restarted.
|
||||||
|
let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5);
|
||||||
|
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_elapsed_yields_no_rates() {
|
||||||
|
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||||
|
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::ZERO);
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_rtt_saturates_instead_of_wrapping() {
|
||||||
|
let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||||
|
cur.rtt = Duration::from_secs(u64::MAX);
|
||||||
|
let info = derive(None, &cur, POLL_INTERVAL);
|
||||||
|
assert_eq!(info.rtt_ms, u32::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-3
@@ -202,15 +202,20 @@ impl JitterBuffer {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Gap with later packets already buffered: a packet was lost
|
// Gap with later packets already buffered: a packet was lost
|
||||||
// or reordered out of window. First try Opus in-band FEC from
|
// or reordered out of window. Try Opus in-band FEC from the
|
||||||
// the next packet; if unavailable, fall back to plain PLC.
|
// packet right after the gap; if that packet isn't buffered
|
||||||
|
// (burst loss) or FEC fails, fall back to plain PLC.
|
||||||
self.next_seq = Some(next.wrapping_add(1));
|
self.next_seq = Some(next.wrapping_add(1));
|
||||||
self.note_disruption();
|
self.note_disruption();
|
||||||
let next_payload = self.packets.values().next().expect("non-empty");
|
let (&smallest, next_payload) = self.packets.iter().next().expect("non-empty");
|
||||||
|
if fec_covers_gap(next, smallest) {
|
||||||
self.decoder
|
self.decoder
|
||||||
.decode_fec(next_payload)
|
.decode_fec(next_payload)
|
||||||
.or_else(|_| self.decoder.decode(None))
|
.or_else(|_| self.decoder.decode(None))
|
||||||
.ok()
|
.ok()
|
||||||
|
} else {
|
||||||
|
self.decoder.decode(None).ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +227,15 @@ impl JitterBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opus in-band FEC in packet N carries a low-fidelity copy of frame N-1 and
|
||||||
|
/// nothing else — a lost frame `next` is FEC-recoverable solely from packet
|
||||||
|
/// `next+1`. Any later successor's FEC data is a different frame's audio, and
|
||||||
|
/// splicing it into this gap plays sound from the wrong position; the caller
|
||||||
|
/// must conceal with plain PLC instead.
|
||||||
|
fn fec_covers_gap(next: u32, smallest_buffered: u32) -> bool {
|
||||||
|
smallest_buffered == next.wrapping_add(1)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -379,6 +393,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fec_covers_gap_only_for_the_immediate_successor() {
|
||||||
|
// Packet next+1 is the only one whose in-band FEC describes frame `next`.
|
||||||
|
assert!(fec_covers_gap(4, 5));
|
||||||
|
// A burst gap: the smallest survivor's FEC is some other frame's audio.
|
||||||
|
assert!(!fec_covers_gap(3, 5));
|
||||||
|
assert!(!fec_covers_gap(3, 3_000));
|
||||||
|
// Sequence wraparound still counts as adjacent.
|
||||||
|
assert!(fec_covers_gap(u32::MAX, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_gap_falls_back_to_plc_not_wrong_position_fec() {
|
||||||
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
enc.apply_params(&OpusParams {
|
||||||
|
bitrate: 20_000,
|
||||||
|
inband_fec: true,
|
||||||
|
packet_loss_perc: 60,
|
||||||
|
dtx: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Frames 0..=6; 3 and 4 are lost as a burst, so when playout reaches
|
||||||
|
// seq 3 the smallest buffered packet is 5 — whose FEC data is frame 4,
|
||||||
|
// NOT frame 3. The buffer must conceal 3 with plain PLC rather than
|
||||||
|
// splice frame 4's audio into the wrong position.
|
||||||
|
let packets: Vec<Vec<u8>> = (0..7).map(|seq| tone_frame(&mut enc, 8_000, seq)).collect();
|
||||||
|
|
||||||
|
// Twin decoder replaying the exact call sequence the jitter buffer
|
||||||
|
// should make for seq 3: decode 0,1,2 then a plain PLC conceal.
|
||||||
|
let mut twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||||
|
for packet in packets.iter().take(3) {
|
||||||
|
twin.decode(Some(packet)).unwrap();
|
||||||
|
}
|
||||||
|
let expected_plc = twin.decode(None).unwrap();
|
||||||
|
|
||||||
|
let mut jb = JitterBuffer::new().unwrap();
|
||||||
|
for (seq, packet) in packets.iter().enumerate() {
|
||||||
|
if seq != 3 && seq != 4 {
|
||||||
|
jb.insert(seq as u32, packet.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seq 3: burst gap — bit-exact PLC (same decoder state, same inputs),
|
||||||
|
// which decode_fec(packet 5) could never produce.
|
||||||
|
let concealed = jb.pop_frame().expect("gap should be concealed");
|
||||||
|
assert_eq!(concealed, expected_plc, "burst gap must use plain PLC");
|
||||||
|
|
||||||
|
// Seq 4: packet 5 IS the immediate successor, so its FEC data is
|
||||||
|
// frame 4's audio — the correctly-positioned recovery still applies.
|
||||||
|
let recovered = jb
|
||||||
|
.pop_frame()
|
||||||
|
.expect("adjacent gap should be reconstructed");
|
||||||
|
let mut fec_twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||||
|
for packet in packets.iter().take(3) {
|
||||||
|
fec_twin.decode(Some(packet)).unwrap();
|
||||||
|
}
|
||||||
|
fec_twin.decode(None).unwrap();
|
||||||
|
let expected_fec = fec_twin.decode_fec(&packets[5]).unwrap();
|
||||||
|
assert_eq!(recovered, expected_fec, "adjacent gap should still use FEC");
|
||||||
|
|
||||||
|
// Then 5 and 6 play normally.
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
assert!(jb.pop_frame().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drops_packets_already_played() {
|
fn drops_packets_already_played() {
|
||||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
|||||||
+78
-14
@@ -1,4 +1,4 @@
|
|||||||
use crate::config::{AudioProfile, NetworkMode, RecordingMode};
|
use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
|
||||||
use crate::friends::Friend;
|
use crate::friends::Friend;
|
||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use crate::presence::{FriendPresence, PresenceMode};
|
use crate::presence::{FriendPresence, PresenceMode};
|
||||||
@@ -66,15 +66,27 @@ pub enum CoreCommand {
|
|||||||
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
||||||
/// effect on the next recording start. Sent at startup from config.
|
/// effect on the next recording start. Sent at startup from config.
|
||||||
SetRecordingMode(RecordingMode),
|
SetRecordingMode(RecordingMode),
|
||||||
/// Broadcast a room text-chat message. No-op when not in a call.
|
/// Broadcast a room text-chat message. `local_id` is the app's local-only
|
||||||
SendChat(String),
|
/// 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 +
|
/// 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
|
/// 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 {
|
SendChatFile {
|
||||||
|
local_id: u64,
|
||||||
text: String,
|
text: String,
|
||||||
attachment: crate::files::ChatAttachment,
|
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
|
/// 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
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||||
@@ -119,13 +131,18 @@ pub enum CoreCommand {
|
|||||||
/// whole desktop audio (the legacy behavior).
|
/// whole desktop audio (the legacy behavior).
|
||||||
StartScreenShare {
|
StartScreenShare {
|
||||||
audio_app: Option<String>,
|
audio_app: Option<String>,
|
||||||
|
settings: ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
},
|
},
|
||||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||||
/// ticket. No-op when not sharing.
|
/// ticket. No-op when not sharing.
|
||||||
StopScreenShare,
|
StopScreenShare,
|
||||||
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
||||||
/// open it in a local player.
|
/// open it in a local player.
|
||||||
ViewShare(String),
|
ViewShare {
|
||||||
|
ticket: String,
|
||||||
|
settings: ScreenShareSettings,
|
||||||
|
},
|
||||||
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
|
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
|
||||||
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
||||||
/// an updated [`UiEvent::IdentityStatus`].
|
/// an updated [`UiEvent::IdentityStatus`].
|
||||||
@@ -219,8 +236,12 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
| CoreCommand::SetAudioProfile(_)
|
| CoreCommand::SetAudioProfile(_)
|
||||||
| CoreCommand::SetRecording(_)
|
| CoreCommand::SetRecording(_)
|
||||||
| CoreCommand::SetRecordingMode(_)
|
| CoreCommand::SetRecordingMode(_)
|
||||||
| CoreCommand::SendChat(_)
|
| CoreCommand::SendChat {
|
||||||
|
local_id: _,
|
||||||
|
text: _,
|
||||||
|
}
|
||||||
| CoreCommand::SendChatFile {
|
| CoreCommand::SendChatFile {
|
||||||
|
local_id: _,
|
||||||
text: _,
|
text: _,
|
||||||
attachment: _,
|
attachment: _,
|
||||||
data: _,
|
data: _,
|
||||||
@@ -244,9 +265,16 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
}
|
}
|
||||||
| CoreCommand::SetPixelpassPath(_)
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
| CoreCommand::ListAudioApps
|
| CoreCommand::ListAudioApps
|
||||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
| CoreCommand::StartScreenShare {
|
||||||
|
audio_app: _,
|
||||||
|
settings: _,
|
||||||
|
quality: _,
|
||||||
|
}
|
||||||
| CoreCommand::StopScreenShare
|
| CoreCommand::StopScreenShare
|
||||||
| CoreCommand::ViewShare(_)
|
| CoreCommand::ViewShare {
|
||||||
|
ticket: _,
|
||||||
|
settings: _,
|
||||||
|
}
|
||||||
| CoreCommand::RegenerateIdentity
|
| CoreCommand::RegenerateIdentity
|
||||||
| CoreCommand::AddFriend {
|
| CoreCommand::AddFriend {
|
||||||
id: _,
|
id: _,
|
||||||
@@ -300,8 +328,12 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
|||||||
| CoreCommand::SetAudioProfile(_)
|
| CoreCommand::SetAudioProfile(_)
|
||||||
| CoreCommand::SetRecording(_)
|
| CoreCommand::SetRecording(_)
|
||||||
| CoreCommand::SetRecordingMode(_)
|
| CoreCommand::SetRecordingMode(_)
|
||||||
| CoreCommand::SendChat(_)
|
| CoreCommand::SendChat {
|
||||||
|
local_id: _,
|
||||||
|
text: _,
|
||||||
|
}
|
||||||
| CoreCommand::SendChatFile {
|
| CoreCommand::SendChatFile {
|
||||||
|
local_id: _,
|
||||||
text: _,
|
text: _,
|
||||||
attachment: _,
|
attachment: _,
|
||||||
data: _,
|
data: _,
|
||||||
@@ -325,9 +357,16 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
|||||||
}
|
}
|
||||||
| CoreCommand::SetPixelpassPath(_)
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
| CoreCommand::ListAudioApps
|
| CoreCommand::ListAudioApps
|
||||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
| CoreCommand::StartScreenShare {
|
||||||
|
audio_app: _,
|
||||||
|
settings: _,
|
||||||
|
quality: _,
|
||||||
|
}
|
||||||
| CoreCommand::StopScreenShare
|
| CoreCommand::StopScreenShare
|
||||||
| CoreCommand::ViewShare(_)
|
| CoreCommand::ViewShare {
|
||||||
|
ticket: _,
|
||||||
|
settings: _,
|
||||||
|
}
|
||||||
| CoreCommand::RegenerateIdentity
|
| CoreCommand::RegenerateIdentity
|
||||||
| CoreCommand::AddFriend {
|
| CoreCommand::AddFriend {
|
||||||
id: _,
|
id: _,
|
||||||
@@ -382,6 +421,11 @@ pub enum UiEvent {
|
|||||||
id: EndpointId,
|
id: EndpointId,
|
||||||
},
|
},
|
||||||
AudioLevels(Vec<(EndpointId, f32)>),
|
AudioLevels(Vec<(EndpointId, f32)>),
|
||||||
|
/// Periodic per-peer connection transparency snapshot (~1/sec): path type
|
||||||
|
/// (direct/relay), RTT, and window loss/bitrate for every peer with a live
|
||||||
|
/// audio link. A FULL replacement each time — a peer absent from the list
|
||||||
|
/// has no live link right now, so its badge should disappear.
|
||||||
|
ConnectionStats(Vec<(EndpointId, crate::core::connstats::PeerConnInfo)>),
|
||||||
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
||||||
/// for the settings level meter. Throttled to ~10/sec.
|
/// for the settings level meter. Throttled to ~10/sec.
|
||||||
MicLevel(f32),
|
MicLevel(f32),
|
||||||
@@ -393,6 +437,15 @@ pub enum UiEvent {
|
|||||||
RecordingStopped {
|
RecordingStopped {
|
||||||
path: String,
|
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
|
/// 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
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
||||||
/// string, used to key their avatar (W4).
|
/// string, used to key their avatar (W4).
|
||||||
@@ -410,7 +463,15 @@ pub enum UiEvent {
|
|||||||
AttachmentReady {
|
AttachmentReady {
|
||||||
from: EndpointId,
|
from: EndpointId,
|
||||||
id: crate::files::AttachmentId,
|
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.).
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
AttachmentFailed {
|
AttachmentFailed {
|
||||||
@@ -581,7 +642,10 @@ mod tests {
|
|||||||
CoreCommand::SetPeerMuted(peer, true),
|
CoreCommand::SetPeerMuted(peer, true),
|
||||||
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
||||||
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
|
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
|
||||||
CoreCommand::SendChat("hello".to_string()),
|
CoreCommand::SendChat {
|
||||||
|
local_id: 1,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for cmd in commands {
|
for cmd in commands {
|
||||||
|
|||||||
+499
-70
@@ -1,3 +1,6 @@
|
|||||||
|
pub mod chatroster;
|
||||||
|
pub mod connstats;
|
||||||
|
pub mod fetchbudget;
|
||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
mod recovery;
|
mod recovery;
|
||||||
@@ -299,6 +302,9 @@ struct GraceExpiry<'a> {
|
|||||||
jitter: &'a Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
jitter: &'a Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
||||||
ui_tx: &'a mpsc::Sender<UiEvent>,
|
ui_tx: &'a mpsc::Sender<UiEvent>,
|
||||||
recovery: Option<&'a RecoveryContext>,
|
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(
|
fn arm_grace_timer(
|
||||||
@@ -318,6 +324,7 @@ fn arm_grace_timer(
|
|||||||
let timers_evict = timers.clone();
|
let timers_evict = timers.clone();
|
||||||
let seen_evict = seen_connected.clone();
|
let seen_evict = seen_connected.clone();
|
||||||
let recovery_evict = expiry.recovery.cloned();
|
let recovery_evict = expiry.recovery.cloned();
|
||||||
|
let chat_roster_evict = expiry.chat_roster.clone();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
tokio::time::sleep(grace).await;
|
tokio::time::sleep(grace).await;
|
||||||
crate::log_msg(&format!("Reconnect grace expired for peer {:?}", peer_id));
|
crate::log_msg(&format!("Reconnect grace expired for peer {:?}", peer_id));
|
||||||
@@ -329,6 +336,9 @@ fn arm_grace_timer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
transport_evict.remove_audio_sender(peer_id);
|
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 {
|
if let Some(recovery) = &recovery_evict {
|
||||||
// Revoke roster authority before the first await in teardown. A
|
// Revoke roster authority before the first await in teardown. A
|
||||||
// verified Announce racing after this point is then a PeerJoined and
|
// verified Announce racing after this point is then a PeerJoined and
|
||||||
@@ -554,6 +564,7 @@ pub struct ConnEventHandler {
|
|||||||
jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
||||||
recovery: Option<RecoveryContext>,
|
recovery: Option<RecoveryContext>,
|
||||||
grace: Duration,
|
grace: Duration,
|
||||||
|
chat_roster: chatroster::ChatRoster,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnEventHandler {
|
impl ConnEventHandler {
|
||||||
@@ -572,6 +583,7 @@ impl ConnEventHandler {
|
|||||||
jitter,
|
jitter,
|
||||||
recovery: None,
|
recovery: None,
|
||||||
grace: RECONNECT_GRACE,
|
grace: RECONNECT_GRACE,
|
||||||
|
chat_roster: chatroster::ChatRoster::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,6 +593,13 @@ impl ConnEventHandler {
|
|||||||
self
|
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 {
|
fn with_recovery(mut self, recovery: RecoveryContext) -> Self {
|
||||||
self.recovery = Some(recovery);
|
self.recovery = Some(recovery);
|
||||||
self
|
self
|
||||||
@@ -604,6 +623,7 @@ impl ConnEventHandler {
|
|||||||
jitter: &self.jitter,
|
jitter: &self.jitter,
|
||||||
ui_tx: &self.ui_tx,
|
ui_tx: &self.ui_tx,
|
||||||
recovery: self.recovery.as_ref(),
|
recovery: self.recovery.as_ref(),
|
||||||
|
chat_roster: &self.chat_roster,
|
||||||
},
|
},
|
||||||
self.grace,
|
self.grace,
|
||||||
id,
|
id,
|
||||||
@@ -652,6 +672,7 @@ struct ActiveSession {
|
|||||||
mixer_task: tokio::task::JoinHandle<()>,
|
mixer_task: tokio::task::JoinHandle<()>,
|
||||||
event_task: tokio::task::JoinHandle<()>,
|
event_task: tokio::task::JoinHandle<()>,
|
||||||
conn_event_task: tokio::task::JoinHandle<()>,
|
conn_event_task: tokio::task::JoinHandle<()>,
|
||||||
|
conn_stats_task: tokio::task::JoinHandle<()>,
|
||||||
recovery_task: tokio::task::JoinHandle<()>,
|
recovery_task: tokio::task::JoinHandle<()>,
|
||||||
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
||||||
grace_timers: GraceTimers,
|
grace_timers: GraceTimers,
|
||||||
@@ -662,9 +683,11 @@ struct ActiveSession {
|
|||||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
||||||
/// also dies if the session is dropped without an explicit stop).
|
/// also dies if the session is dropped without an explicit stop).
|
||||||
screenshare_host: Option<tokio::process::Child>,
|
screenshare_host: Option<tokio::process::Child>,
|
||||||
/// pixelpass viewer children we spawned to watch peers' shares; killed on
|
/// pixelpass viewer children we spawned to watch peers' shares, each paired
|
||||||
/// session teardown (each also self-exits when its player window closes).
|
/// with the share ticket it's viewing so a re-watch of the same share can
|
||||||
screenshare_viewers: Vec<tokio::process::Child>,
|
/// replace (not stack) its player. Killed on session teardown (each also
|
||||||
|
/// self-exits when its player window closes).
|
||||||
|
screenshare_viewers: Vec<(String, tokio::process::Child)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveSession {
|
impl ActiveSession {
|
||||||
@@ -676,13 +699,14 @@ impl ActiveSession {
|
|||||||
if let Some(mut host) = self.screenshare_host.take() {
|
if let Some(mut host) = self.screenshare_host.take() {
|
||||||
let _ = host.kill().await;
|
let _ = host.kill().await;
|
||||||
}
|
}
|
||||||
for mut viewer in self.screenshare_viewers.drain(..) {
|
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
|
||||||
let _ = viewer.kill().await;
|
let _ = viewer.kill().await;
|
||||||
}
|
}
|
||||||
self.datagram_task.abort();
|
self.datagram_task.abort();
|
||||||
self.mixer_task.abort();
|
self.mixer_task.abort();
|
||||||
self.event_task.abort();
|
self.event_task.abort();
|
||||||
self.conn_event_task.abort();
|
self.conn_event_task.abort();
|
||||||
|
self.conn_stats_task.abort();
|
||||||
// Abort any pending reconnect grace timers so they can't fire a stray
|
// Abort any pending reconnect grace timers so they can't fire a stray
|
||||||
// eviction (or touch a torn-down transport) after the session is gone.
|
// eviction (or touch a torn-down transport) after the session is gone.
|
||||||
for (_, handle) in self.grace_timers.lock().unwrap().drain() {
|
for (_, handle) in self.grace_timers.lock().unwrap().drain() {
|
||||||
@@ -883,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.
|
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
|
||||||
///
|
///
|
||||||
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
|
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
|
||||||
@@ -897,6 +1018,15 @@ const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4;
|
|||||||
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
|
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
|
||||||
type InflightAttachments = Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
|
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
|
/// 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
|
/// 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.
|
/// fetch finishes (success OR failure), so the same image can be retried later.
|
||||||
@@ -913,11 +1043,22 @@ impl Drop for AutoFetchGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether to AUTO-fetch a chat image attachment. Only authenticated roster
|
/// Whether to AUTO-fetch a chat image attachment. Only authenticated roster
|
||||||
/// authors qualify (closing the non-roster injection vector), and a `(author,
|
/// authors qualify (closing the non-roster injection vector), only declared
|
||||||
/// id)` already being fetched is skipped (dedup). The concurrency bound itself is
|
/// sizes at or under [`crate::files::MAX_AUTO_IMAGE_BYTES`] (larger images get
|
||||||
/// enforced separately by the permit. Pure → unit-testable (Tier C F-02).
|
/// a Load button instead, Phase 3B), and a `(author, id)` already being fetched
|
||||||
fn should_auto_fetch(is_image: bool, author_in_roster: bool, already_inflight: bool) -> bool {
|
/// is skipped (dedup). The concurrency bound (permits) and the byte/request
|
||||||
is_image && author_in_roster && !already_inflight
|
/// 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
|
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
|
||||||
@@ -940,6 +1081,12 @@ fn spawn_attachment_fetch(
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Held for the whole fetch; dropped here on completion (Tier C F-02).
|
// Held for the whole fetch; dropped here on completion (Tier C F-02).
|
||||||
let _guard = guard;
|
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 {
|
match transport.fetch_attachment(from, &att).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if is_image && crate::files::validate_image_bytes(&data).is_none() {
|
if is_image && crate::files::validate_image_bytes(&data).is_none() {
|
||||||
@@ -956,7 +1103,7 @@ fn spawn_attachment_fetch(
|
|||||||
.send(UiEvent::AttachmentReady {
|
.send(UiEvent::AttachmentReady {
|
||||||
from,
|
from,
|
||||||
id: att.id,
|
id: att.id,
|
||||||
data,
|
data: Arc::new(data),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1330,6 +1477,10 @@ async fn run_core_loop(
|
|||||||
// rebuilt on the next Leave (or before the next Join), preserving the old
|
// rebuilt on the next Leave (or before the next Join), preserving the old
|
||||||
// "applies on next join" semantics while keeping the endpoint up while idle.
|
// "applies on next join" semantics while keeping the endpoint up while idle.
|
||||||
let mut net_rebuild_pending = false;
|
let mut net_rebuild_pending = false;
|
||||||
|
// The posture the live stack was actually built with. Trails `network_mode`
|
||||||
|
// while a rebuild is pending, and is the fallback posture when a rebuild
|
||||||
|
// fails (see `replace_net_stack`).
|
||||||
|
let mut net_mode = network_mode;
|
||||||
|
|
||||||
// When Discoverable is on, the instant it auto-reverts to Normal (W7 P6 time-box).
|
// When Discoverable is on, the instant it auto-reverts to Normal (W7 P6 time-box).
|
||||||
// `None` = not Discoverable, no pending revert. Set on SetPresenceMode(Discoverable),
|
// `None` = not Discoverable, no pending revert. Set on SetPresenceMode(Discoverable),
|
||||||
@@ -1547,17 +1698,24 @@ async fn run_core_loop(
|
|||||||
// active, rebuild the persistent stack now — after the old session is
|
// active, rebuild the persistent stack now — after the old session is
|
||||||
// gone, before the new one binds — so this join uses the new posture.
|
// gone, before the new one binds — so this join uses the new posture.
|
||||||
if net_rebuild_pending {
|
if net_rebuild_pending {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
let (stack, live) = replace_net_stack(
|
||||||
secret_key.clone(),
|
net,
|
||||||
|
"Applying deferred network settings",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
|
// If the new posture failed and we fell back, keep the mode
|
||||||
|
// state honest (and re-attemptable) rather than pretending
|
||||||
|
// the change applied. The join proceeds on the live stack.
|
||||||
|
network_mode = live;
|
||||||
net_rebuild_pending = false;
|
net_rebuild_pending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2220,17 +2378,27 @@ async fn run_core_loop(
|
|||||||
Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_ATTACHMENT_FETCHES));
|
Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_ATTACHMENT_FETCHES));
|
||||||
let inflight_attachments: InflightAttachments =
|
let inflight_attachments: InflightAttachments =
|
||||||
Arc::new(std::sync::Mutex::new(HashSet::new()));
|
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 {
|
let event_task = tokio::spawn(async move {
|
||||||
// The authenticated roster for this room, maintained from the
|
let roster = chat_roster_events;
|
||||||
// same sequential event stream. Only its members may trigger an
|
// Byte/request budgets for automatic attachment fetches
|
||||||
// automatic attachment fetch (Tier C F-02).
|
// (Phase 3B). Only this sequential task consults it, so it
|
||||||
let mut roster: HashSet<EndpointId> = HashSet::new();
|
// 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 {
|
while let Some(event) = room_events.recv().await {
|
||||||
match event {
|
match event {
|
||||||
RoomEvent::PeerJoined(peer_id, state) => {
|
RoomEvent::PeerJoined(peer_id, state) => {
|
||||||
// A (re)join means the peer is back — cancel any
|
// A (re)join means the peer is back — cancel any
|
||||||
// pending reconnect grace timer before re-adding it.
|
// 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);
|
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||||
recovery_events.cancel(peer_id);
|
recovery_events.cancel(peer_id);
|
||||||
transport_events.admit_audio_sender(peer_id);
|
transport_events.admit_audio_sender(peer_id);
|
||||||
@@ -2286,7 +2454,8 @@ async fn run_core_loop(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
RoomEvent::PeerLeft(peer_id) => {
|
RoomEvent::PeerLeft(peer_id) => {
|
||||||
// Graceful leave — evict immediately.
|
// Graceful leave — evict immediately (chat authority
|
||||||
|
// and roster-bound name included).
|
||||||
roster.remove(&peer_id);
|
roster.remove(&peer_id);
|
||||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||||
seen_connected_events.lock().unwrap().remove(&peer_id);
|
seen_connected_events.lock().unwrap().remove(&peer_id);
|
||||||
@@ -2299,6 +2468,11 @@ async fn run_core_loop(
|
|||||||
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
|
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
|
||||||
}
|
}
|
||||||
RoomEvent::PeerUpdated(peer_id, state) => {
|
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
|
// A re-announce means the peer is alive — cancel any
|
||||||
// pending grace timer. It may also carry a fresh
|
// pending grace timer. It may also carry a fresh
|
||||||
// address (peer back on a new network); refresh the
|
// address (peer back on a new network); refresh the
|
||||||
@@ -2346,11 +2520,26 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
RoomEvent::ChatMessage {
|
RoomEvent::ChatMessage {
|
||||||
from,
|
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,
|
text,
|
||||||
ts: _,
|
ts: _,
|
||||||
attachment,
|
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
|
// Auto-fetch image attachments so they render inline
|
||||||
// without a click; non-image files wait for an explicit
|
// without a click; non-image files wait for an explicit
|
||||||
// FetchAttachment (the "Save" chip). The descriptor was
|
// FetchAttachment (the "Save" chip). The descriptor was
|
||||||
@@ -2369,9 +2558,14 @@ async fn run_core_loop(
|
|||||||
inflight_attachments.lock().unwrap().contains(&key);
|
inflight_attachments.lock().unwrap().contains(&key);
|
||||||
if should_auto_fetch(
|
if should_auto_fetch(
|
||||||
is_image,
|
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,
|
already_inflight,
|
||||||
) {
|
att.size,
|
||||||
|
) && auto_fetch_budget.admit(from, att.size, unix_now_ms())
|
||||||
|
{
|
||||||
// Reserve the dedup slot, then a permit. If the
|
// Reserve the dedup slot, then a permit. If the
|
||||||
// pool is exhausted, drop the auto-fetch (and the
|
// pool is exhausted, drop the auto-fetch (and the
|
||||||
// dedup marker) — the descriptor still shows and
|
// dedup marker) — the descriptor still shows and
|
||||||
@@ -2443,6 +2637,7 @@ async fn run_core_loop(
|
|||||||
jitter: &jitter_events,
|
jitter: &jitter_events,
|
||||||
ui_tx: &ui_tx_events,
|
ui_tx: &ui_tx_events,
|
||||||
recovery: Some(&recovery_events),
|
recovery: Some(&recovery_events),
|
||||||
|
chat_roster: &roster,
|
||||||
},
|
},
|
||||||
RECONNECT_GRACE,
|
RECONNECT_GRACE,
|
||||||
peer_id,
|
peer_id,
|
||||||
@@ -2476,13 +2671,48 @@ async fn run_core_loop(
|
|||||||
transport.clone(),
|
transport.clone(),
|
||||||
jitter.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 {
|
let conn_event_task = tokio::spawn(async move {
|
||||||
while let Some(event) = conn_events.recv().await {
|
while let Some(event) = conn_events.recv().await {
|
||||||
conn_handler.handle(event).await;
|
conn_handler.handle(event).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Connection-transparency poll: ~1/sec, snapshot every live audio
|
||||||
|
// link's selected path and hand the UI derived badge info (path
|
||||||
|
// type, RTT, window loss/bitrate). Read-only against the
|
||||||
|
// transport; owns the previous-snapshot map the derivation diffs
|
||||||
|
// against.
|
||||||
|
let transport_stats = transport.clone();
|
||||||
|
let ui_tx_stats = ui_tx.clone();
|
||||||
|
let conn_stats_task = tokio::spawn(async move {
|
||||||
|
let mut prev: HashMap<EndpointId, crate::network::PathSnapshot> =
|
||||||
|
HashMap::new();
|
||||||
|
let mut last = tokio::time::Instant::now();
|
||||||
|
let mut ticker = tokio::time::interval(connstats::POLL_INTERVAL);
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let elapsed = now - last;
|
||||||
|
last = now;
|
||||||
|
let snaps = transport_stats.connection_stats();
|
||||||
|
let infos = snaps
|
||||||
|
.iter()
|
||||||
|
.map(|(id, cur)| (*id, connstats::derive(prev.get(id), cur, elapsed)))
|
||||||
|
.collect();
|
||||||
|
prev = snaps.into_iter().collect();
|
||||||
|
if ui_tx_stats
|
||||||
|
.send(UiEvent::ConnectionStats(infos))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let session = ActiveSession {
|
let session = ActiveSession {
|
||||||
room_state: room_state.clone(),
|
room_state: room_state.clone(),
|
||||||
capture_thread,
|
capture_thread,
|
||||||
@@ -2490,6 +2720,7 @@ async fn run_core_loop(
|
|||||||
mixer_task,
|
mixer_task,
|
||||||
event_task,
|
event_task,
|
||||||
conn_event_task,
|
conn_event_task,
|
||||||
|
conn_stats_task,
|
||||||
recovery_task,
|
recovery_task,
|
||||||
recovery_terminal_task,
|
recovery_terminal_task,
|
||||||
grace_timers,
|
grace_timers,
|
||||||
@@ -2497,7 +2728,7 @@ async fn run_core_loop(
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
echo_cancel: echo_cancel_guard,
|
echo_cancel: echo_cancel_guard,
|
||||||
screenshare_host: None,
|
screenshare_host: None,
|
||||||
screenshare_viewers: Vec::new(),
|
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let self_id = endpoint.id().to_string();
|
let self_id = endpoint.id().to_string();
|
||||||
@@ -2554,17 +2785,21 @@ async fn run_core_loop(
|
|||||||
// Apply any network-mode / identity change that was deferred while we
|
// Apply any network-mode / identity change that was deferred while we
|
||||||
// were in the call (rebuild while idle keeps the endpoint reachable).
|
// were in the call (rebuild while idle keeps the endpoint reachable).
|
||||||
if net_rebuild_pending {
|
if net_rebuild_pending {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
let (stack, live) = replace_net_stack(
|
||||||
secret_key.clone(),
|
net,
|
||||||
|
"Applying deferred network settings",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
|
network_mode = live;
|
||||||
net_rebuild_pending = false;
|
net_rebuild_pending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2710,17 +2945,21 @@ async fn run_core_loop(
|
|||||||
// idle; if a call is active, defer to the next Leave/Join so the
|
// idle; if a call is active, defer to the next Leave/Join so the
|
||||||
// live call isn't disrupted (preserves "applies on next join").
|
// live call isn't disrupted (preserves "applies on next join").
|
||||||
if active_session.is_none() {
|
if active_session.is_none() {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
let (stack, live) = replace_net_stack(
|
||||||
secret_key.clone(),
|
net,
|
||||||
|
"Network mode change",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
|
network_mode = live;
|
||||||
} else {
|
} else {
|
||||||
net_rebuild_pending = true;
|
net_rebuild_pending = true;
|
||||||
}
|
}
|
||||||
@@ -2758,17 +2997,22 @@ async fn run_core_loop(
|
|||||||
// key unchanged, so a rebuild would be pointless churn).
|
// key unchanged, so a rebuild would be pointless churn).
|
||||||
if regenerated {
|
if regenerated {
|
||||||
if active_session.is_none() {
|
if active_session.is_none() {
|
||||||
let lookup = net.memory_lookup.clone();
|
|
||||||
net.shutdown().await;
|
|
||||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||||
net = build_net_stack(
|
// Same mode both attempts — the fallback is a plain
|
||||||
secret_key.clone(),
|
// retry under the (already persisted) new key.
|
||||||
|
let (stack, live) = replace_net_stack(
|
||||||
|
net,
|
||||||
|
"Endpoint restart after identity change",
|
||||||
|
&secret_key,
|
||||||
network_mode,
|
network_mode,
|
||||||
lookup,
|
net_mode,
|
||||||
friends_handler.clone(),
|
&friends_handler,
|
||||||
publish,
|
publish,
|
||||||
|
&ui_tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
net = stack;
|
||||||
|
net_mode = live;
|
||||||
} else {
|
} else {
|
||||||
net_rebuild_pending = true;
|
net_rebuild_pending = true;
|
||||||
}
|
}
|
||||||
@@ -3015,29 +3259,54 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SendChat(text) => {
|
CoreCommand::SendChat { local_id, text } => {
|
||||||
if let Some(session) = &active_session
|
// Honest result either way (Phase 5): no active session is a
|
||||||
&& let Err(e) = session.room_state.send_chat(text, None).await
|
// 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}"));
|
crate::log_msg(&format!("Failed to send chat: {e}"));
|
||||||
}
|
}
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::ChatSendResult { local_id, error })
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SendChatFile {
|
CoreCommand::SendChatFile {
|
||||||
|
local_id,
|
||||||
text,
|
text,
|
||||||
attachment,
|
attachment,
|
||||||
data,
|
data,
|
||||||
} => {
|
} => {
|
||||||
if let Some(session) = &active_session {
|
let error = match &active_session {
|
||||||
// Make the bytes fetchable by room members, then broadcast the
|
Some(session) => {
|
||||||
// descriptor alongside the (possibly empty) caption text.
|
// 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
|
session
|
||||||
.transport
|
.room_state
|
||||||
.serve_attachment(attachment.id, Arc::new(data));
|
.send_chat(text, Some(attachment))
|
||||||
if let Err(e) = session.room_state.send_chat(text, Some(attachment)).await {
|
.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}"));
|
crate::log_msg(&format!("Failed to send chat file: {e}"));
|
||||||
}
|
}
|
||||||
}
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::ChatSendResult { local_id, error })
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::FetchAttachment { from, attachment } => {
|
CoreCommand::FetchAttachment { from, attachment } => {
|
||||||
@@ -3121,7 +3390,11 @@ async fn run_core_loop(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::StartScreenShare { audio_app } => {
|
CoreCommand::StartScreenShare {
|
||||||
|
audio_app,
|
||||||
|
settings,
|
||||||
|
quality,
|
||||||
|
} => {
|
||||||
let Some(session) = &mut active_session else {
|
let Some(session) = &mut active_session else {
|
||||||
let _ = ui_tx
|
let _ = ui_tx
|
||||||
.send(UiEvent::Error(
|
.send(UiEvent::Error(
|
||||||
@@ -3171,7 +3444,15 @@ async fn run_core_loop(
|
|||||||
});
|
});
|
||||||
tx
|
tx
|
||||||
});
|
});
|
||||||
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
|
match crate::screenshare::spawn_host(
|
||||||
|
&bin,
|
||||||
|
audio_app.as_deref(),
|
||||||
|
&settings,
|
||||||
|
quality,
|
||||||
|
notices,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok((child, ticket)) => {
|
Ok((child, ticket)) => {
|
||||||
crate::log_msg("Screen share host started");
|
crate::log_msg("Screen share host started");
|
||||||
session.screenshare_host = Some(child);
|
session.screenshare_host = Some(child);
|
||||||
@@ -3209,7 +3490,7 @@ async fn run_core_loop(
|
|||||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::ViewShare(ticket) => {
|
CoreCommand::ViewShare { ticket, settings } => {
|
||||||
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => {
|
None => {
|
||||||
@@ -3221,11 +3502,27 @@ async fn run_core_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match crate::screenshare::spawn_viewer(&bin, &ticket).await {
|
if let Some(session) = &mut active_session {
|
||||||
|
// Drop viewers whose player window has already closed so the
|
||||||
|
// list only tracks live players.
|
||||||
|
session
|
||||||
|
.screenshare_viewers
|
||||||
|
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
|
||||||
|
// One player per share: a second Watch click on a share we're
|
||||||
|
// already viewing is a retry (usually because the first window
|
||||||
|
// froze), so replace the existing player rather than stacking a
|
||||||
|
// second mpv — two players would double the shared audio.
|
||||||
|
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
|
||||||
|
let (_, mut old) = session.screenshare_viewers.remove(pos);
|
||||||
|
let _ = old.kill().await;
|
||||||
|
crate::log_msg("Screen share viewer replaced (re-watch)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match crate::screenshare::spawn_viewer(&bin, &ticket, &settings).await {
|
||||||
Ok(child) => {
|
Ok(child) => {
|
||||||
crate::log_msg("Screen share viewer started");
|
crate::log_msg("Screen share viewer started");
|
||||||
if let Some(session) = &mut active_session {
|
if let Some(session) = &mut active_session {
|
||||||
session.screenshare_viewers.push(child);
|
session.screenshare_viewers.push((ticket, child));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -3241,14 +3538,23 @@ async fn run_core_loop(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Index of an existing viewer for `ticket` in the live-viewers list, if any.
|
||||||
|
/// A re-watch of the same share replaces that player instead of stacking a
|
||||||
|
/// second one — two players decoding the same stream would double the shared
|
||||||
|
/// audio. Generic over the child value so the dedup rule is unit-testable
|
||||||
|
/// without spawning real player processes.
|
||||||
|
fn replace_viewer_index<T>(viewers: &[(String, T)], ticket: &str) -> Option<usize> {
|
||||||
|
viewers.iter().position(|(t, _)| t == ticket)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
|
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
|
||||||
PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained, apply_peer_volume,
|
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
|
||||||
apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop, frame_level,
|
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop,
|
||||||
mix_frames, mix_stereo_frames, next_game_change, send_playback_frame, should_auto_fetch,
|
frame_level, mix_frames, mix_stereo_frames, next_game_change, rebuild_with_fallback,
|
||||||
stereo_to_mono,
|
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono,
|
||||||
};
|
};
|
||||||
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
|
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -3259,6 +3565,114 @@ mod tests {
|
|||||||
iroh::SecretKey::generate().public()
|
iroh::SecretKey::generate().public()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn re_watch_replaces_existing_viewer_for_same_ticket() {
|
||||||
|
// The value type stands in for a viewer Child; only the ticket matters.
|
||||||
|
let viewers = vec![("ticket-A".to_string(), 0u8), ("ticket-B".to_string(), 1u8)];
|
||||||
|
// Re-watching an already-open share finds the existing player to replace.
|
||||||
|
assert_eq!(replace_viewer_index(&viewers, "ticket-A"), Some(0));
|
||||||
|
assert_eq!(replace_viewer_index(&viewers, "ticket-B"), Some(1));
|
||||||
|
// A different (new) share has nothing to replace — it opens fresh.
|
||||||
|
assert_eq!(replace_viewer_index(&viewers, "ticket-C"), None);
|
||||||
|
// Empty list: first watch of anything opens fresh.
|
||||||
|
assert_eq!(replace_viewer_index::<u8>(&[], "ticket-A"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild_with_fallback: the retry policy behind replace_net_stack ---
|
||||||
|
// The builder is injected, so these cover the policy without sockets. The
|
||||||
|
// closure does its bookkeeping synchronously and returns a ready future.
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_keeps_requested_posture_on_first_success() {
|
||||||
|
let calls = std::cell::RefCell::new(Vec::new());
|
||||||
|
let out = rebuild_with_fallback(
|
||||||
|
|mode| {
|
||||||
|
calls.borrow_mut().push(mode);
|
||||||
|
std::future::ready(Ok::<u8, String>(7))
|
||||||
|
},
|
||||||
|
NetworkMode::DirectOnly,
|
||||||
|
NetworkMode::N0Full,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(out, Ok((7, NetworkMode::DirectOnly, None)));
|
||||||
|
// No second build: the live posture is only a fallback.
|
||||||
|
assert_eq!(*calls.borrow(), vec![NetworkMode::DirectOnly]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_falls_back_to_the_live_posture_when_the_requested_one_fails() {
|
||||||
|
let calls = std::cell::RefCell::new(Vec::new());
|
||||||
|
let out = rebuild_with_fallback(
|
||||||
|
|mode| {
|
||||||
|
calls.borrow_mut().push(mode);
|
||||||
|
std::future::ready(if mode == NetworkMode::DirectOnly {
|
||||||
|
Err("bind failed".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(7u8)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
NetworkMode::DirectOnly,
|
||||||
|
NetworkMode::N0Full,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// A stack is up on the OLD posture and the caller learns both that it
|
||||||
|
// fell back (mode) and why (the primary error) — no silent zombie.
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
Ok((7, NetworkMode::N0Full, Some("bind failed".to_string())))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*calls.borrow(),
|
||||||
|
vec![NetworkMode::DirectOnly, NetworkMode::N0Full]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_reports_both_errors_when_networking_is_gone() {
|
||||||
|
let out = rebuild_with_fallback(
|
||||||
|
|_| std::future::ready(Err::<u8, String>("bind failed".to_string())),
|
||||||
|
NetworkMode::DirectOnly,
|
||||||
|
NetworkMode::N0Full,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
Err(("bind failed".to_string(), "bind failed".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebuild_with_equal_postures_is_a_plain_retry() {
|
||||||
|
// RegenerateIdentity rebuilds under the same mode: the fallback is a
|
||||||
|
// second attempt with identical parameters, not a posture change.
|
||||||
|
let calls = std::cell::Cell::new(0u8);
|
||||||
|
let out = rebuild_with_fallback(
|
||||||
|
|mode| {
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
assert_eq!(mode, NetworkMode::RelayNoDiscovery);
|
||||||
|
std::future::ready(if calls.get() == 1 {
|
||||||
|
Err("transient".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(7u8)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
NetworkMode::RelayNoDiscovery,
|
||||||
|
NetworkMode::RelayNoDiscovery,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Succeeded on the requested posture, so the caller treats the change
|
||||||
|
// as applied (the Some(err) is logged, not surfaced as a UI error).
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
Ok((
|
||||||
|
7,
|
||||||
|
NetworkMode::RelayNoDiscovery,
|
||||||
|
Some("transient".to_string())
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(calls.get(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn admit_retained_rejects_only_new_ids_at_the_cap() {
|
fn admit_retained_rejects_only_new_ids_at_the_cap() {
|
||||||
// Below the cap, a brand-new identity is retained.
|
// Below the cap, a brand-new identity is retained.
|
||||||
@@ -3397,15 +3811,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_fetch_only_for_roster_images_not_already_inflight() {
|
fn auto_fetch_only_for_roster_images_not_already_inflight() {
|
||||||
// The happy path: a roster author's brand-new image attachment.
|
const OK_SIZE: u64 = 1024;
|
||||||
assert!(should_auto_fetch(true, true, false));
|
// 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".
|
// 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,
|
// A non-roster author (e.g. a sock puppet that never announced) is rejected,
|
||||||
// closing the F-02 unbounded-task vector.
|
// 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.
|
// 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]
|
#[test]
|
||||||
|
|||||||
+341
-10
@@ -22,6 +22,22 @@ pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
|
|||||||
/// the byte cap. Applied via `image::Limits` when validating/decoding.
|
/// the byte cap. Applied via `image::Limits` when validating/decoding.
|
||||||
pub const MAX_IMAGE_PX: u32 = 4096;
|
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
|
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
|
||||||
/// the UI tidy; the real bytes are unaffected.
|
/// the UI tidy; the real bytes are unaffected.
|
||||||
pub const MAX_FILENAME_LEN: usize = 96;
|
pub const MAX_FILENAME_LEN: usize = 96;
|
||||||
@@ -75,8 +91,14 @@ pub fn sanitize_filename(raw: &str) -> String {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Drop control chars; turn other whitespace into single spaces later.
|
// Drop control chars and the same bidi/zero-width spoofing format chars
|
||||||
let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
|
// 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 = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
let collapsed = collapsed.trim_matches('.').trim();
|
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
|
/// 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.
|
/// `image` feature set; anything else returns `None` and the caller shows a chip.
|
||||||
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
|
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
let mut limits = image::Limits::default();
|
let img = decode_image_bounded(bytes)?;
|
||||||
limits.max_image_width = Some(MAX_IMAGE_PX);
|
Some((img.width(), img.height()))
|
||||||
limits.max_image_height = Some(MAX_IMAGE_PX);
|
}
|
||||||
|
|
||||||
|
/// 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))
|
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let mut reader = reader;
|
let (w, h) = reader.into_dimensions().ok()?;
|
||||||
reader.limits(limits);
|
|
||||||
let img = reader.decode().ok()?;
|
|
||||||
let (w, h) = (img.width(), img.height());
|
|
||||||
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
||||||
return None;
|
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
|
/// 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)));
|
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]
|
#[test]
|
||||||
fn human_size_units() {
|
fn human_size_units() {
|
||||||
assert_eq!(human_size(40), "40 B");
|
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");
|
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]
|
#[test]
|
||||||
fn attachment_descriptor_round_trips_json() {
|
fn attachment_descriptor_round_trips_json() {
|
||||||
let a = ChatAttachment {
|
let a = ChatAttachment {
|
||||||
|
|||||||
+553
-9
@@ -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
|
/// 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`
|
/// `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
|
/// (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)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct GossipPayload {
|
pub struct GossipPayload {
|
||||||
pub author: EndpointId,
|
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.
|
/// 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
|
/// 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 state_mutations_seen = HashMap::new();
|
||||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
||||||
|
let mut chat_gate = ChatIngressGate::new(now_millis());
|
||||||
|
|
||||||
// Broadcast initial state
|
// Broadcast initial state
|
||||||
let initial_payload = {
|
let initial_payload = {
|
||||||
@@ -793,6 +1031,10 @@ impl RoomState for IrohGossipState {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.remove(&payload.author);
|
.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 {
|
if removed || was_disconnected {
|
||||||
let _ = event_tx
|
let _ = event_tx
|
||||||
.send(RoomEvent::PeerLeft(payload.author))
|
.send(RoomEvent::PeerLeft(payload.author))
|
||||||
@@ -802,9 +1044,43 @@ impl RoomState for IrohGossipState {
|
|||||||
GossipMessage::Chat {
|
GossipMessage::Chat {
|
||||||
name,
|
name,
|
||||||
text,
|
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,
|
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!(
|
crate::log_msg(&format!(
|
||||||
"Gossip chat from author={:?}",
|
"Gossip chat from author={:?}",
|
||||||
payload.author
|
payload.author
|
||||||
@@ -819,12 +1095,30 @@ impl RoomState for IrohGossipState {
|
|||||||
a.name = crate::files::sanitize_filename(&a.name);
|
a.name = crate::files::sanitize_filename(&a.name);
|
||||||
Some(a)
|
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
|
let _ = event_tx
|
||||||
.send(RoomEvent::ChatMessage {
|
.send(RoomEvent::ChatMessage {
|
||||||
from: payload.author,
|
from: payload.author,
|
||||||
name,
|
name,
|
||||||
text,
|
text,
|
||||||
ts,
|
// Only the SIGNED envelope timestamp travels
|
||||||
|
// downstream (never used for replay/ordering —
|
||||||
|
// the gate above already handled replay).
|
||||||
|
ts: payload.ts,
|
||||||
attachment,
|
attachment,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -966,6 +1260,14 @@ impl RoomState for IrohGossipState {
|
|||||||
text: String,
|
text: String,
|
||||||
attachment: Option<crate::files::ChatAttachment>,
|
attachment: Option<crate::files::ChatAttachment>,
|
||||||
) -> Result<(), NetError> {
|
) -> 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 name = {
|
||||||
let guard = self.self_state.lock().unwrap();
|
let guard = self.self_state.lock().unwrap();
|
||||||
match guard.as_ref() {
|
match guard.as_ref() {
|
||||||
@@ -977,7 +1279,12 @@ impl RoomState for IrohGossipState {
|
|||||||
|
|
||||||
let sender_opt = self.active_sender.lock().unwrap().clone();
|
let sender_opt = self.active_sender.lock().unwrap().clone();
|
||||||
let topic_opt = *self.active_topic_bytes.lock().unwrap();
|
let topic_opt = *self.active_topic_bytes.lock().unwrap();
|
||||||
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
// 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(
|
let payload = sign_gossip(
|
||||||
&self.secret_key,
|
&self.secret_key,
|
||||||
&topic,
|
&topic,
|
||||||
@@ -989,14 +1296,12 @@ impl RoomState for IrohGossipState {
|
|||||||
attachment,
|
attachment,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
let bytes = serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| NetError::Other(format!("Failed to encode chat: {e}")))?;
|
||||||
sender
|
sender
|
||||||
.broadcast(bytes.into())
|
.broadcast(bytes.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
.map_err(|e| NetError::Gossip(e.to_string()))
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn leave(&self) -> Result<(), NetError> {
|
async fn leave(&self) -> Result<(), NetError> {
|
||||||
@@ -1664,4 +1969,243 @@ mod tests {
|
|||||||
5
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ struct Shared {
|
|||||||
/// the random attachment id. Populated when we send a chat file; read by the
|
/// 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
|
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
|
||||||
/// blob is already byte-capped at send time.
|
/// 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)>,
|
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||||
/// Best-effort link-state notifications for the UI (connecting / connected).
|
/// Best-effort link-state notifications for the UI (connecting / connected).
|
||||||
conn_events_tx: mpsc::Sender<ConnEvent>,
|
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 {
|
let Some(id) = crate::files::parse_request(&req) else {
|
||||||
return Ok(());
|
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 {
|
if let Some(blob) = blob {
|
||||||
let _ = send.write_all(&blob).await;
|
let _ = send.write_all(&blob).await;
|
||||||
}
|
}
|
||||||
@@ -563,7 +565,7 @@ impl IrohTransport {
|
|||||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||||
live_conns: StdMutex::new(HashMap::new()),
|
live_conns: StdMutex::new(HashMap::new()),
|
||||||
admitted_audio: StdMutex::new(HashSet::new()),
|
admitted_audio: StdMutex::new(HashSet::new()),
|
||||||
served_files: StdMutex::new(HashMap::new()),
|
served_files: StdMutex::new(crate::files::ServeStore::default()),
|
||||||
incoming_tx,
|
incoming_tx,
|
||||||
conn_events_tx,
|
conn_events_tx,
|
||||||
});
|
});
|
||||||
@@ -634,7 +636,9 @@ impl IrohTransport {
|
|||||||
/// session (served by the [`FileRouter`] handler). Called by core when we
|
/// session (served by the [`FileRouter`] handler). Called by core when we
|
||||||
/// send a chat file. The blob is cleared on leave.
|
/// send a chat file. The blob is cleared on leave.
|
||||||
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
|
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).
|
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
|
||||||
@@ -676,6 +680,8 @@ impl IrohTransport {
|
|||||||
send.finish()
|
send.finish()
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
.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 read = recv.read_to_end(size as usize);
|
||||||
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
||||||
.await
|
.await
|
||||||
@@ -686,9 +692,68 @@ impl IrohTransport {
|
|||||||
"file fetch: sender no longer has the file".to_string(),
|
"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)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot the selected QUIC path of every live audio connection, for the
|
||||||
|
/// UI's per-peer connection badge (direct/relay, RTT, loss, bitrate).
|
||||||
|
/// Cheap and lock-light: the `live_conns` guard is released before touching
|
||||||
|
/// any connection, and `Connection::paths()` reads shared state without I/O.
|
||||||
|
pub fn connection_stats(&self) -> Vec<(EndpointId, crate::network::PathSnapshot)> {
|
||||||
|
// Clone the connections out so the map lock isn't held while we inspect
|
||||||
|
// paths (a supervisor inserts/removes entries as links come and go).
|
||||||
|
let conns: Vec<(EndpointId, Connection)> = self
|
||||||
|
.shared
|
||||||
|
.live_conns
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, conn)| (*id, conn.clone()))
|
||||||
|
.collect();
|
||||||
|
conns
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, conn)| {
|
||||||
|
let paths = conn.paths();
|
||||||
|
// The selected path is the one carrying application data. In the
|
||||||
|
// brief window where none is flagged (e.g. mid-migration), fall
|
||||||
|
// back to the first open path rather than dropping the badge.
|
||||||
|
let path = paths
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.is_selected())
|
||||||
|
.or_else(|| paths.iter().next())?;
|
||||||
|
let stats = path.stats();
|
||||||
|
// Per-variant display: `TransportAddr`'s own `Display` prefixes
|
||||||
|
// a scheme ("ip:1.2.3.4:5") that's noise next to the badge's
|
||||||
|
// Direct/Relay label.
|
||||||
|
let remote_addr = match path.remote_addr() {
|
||||||
|
iroh::TransportAddr::Ip(sock) => sock.to_string(),
|
||||||
|
iroh::TransportAddr::Relay(url) => url.to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
id,
|
||||||
|
crate::network::PathSnapshot {
|
||||||
|
is_relay: path.remote_addr().is_relay(),
|
||||||
|
remote_addr,
|
||||||
|
rtt: stats.rtt,
|
||||||
|
tx_bytes: stats.udp_tx.bytes,
|
||||||
|
rx_bytes: stats.udp_rx.bytes,
|
||||||
|
tx_datagrams: stats.udp_tx.datagrams,
|
||||||
|
lost_packets: stats.lost_packets,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a chat attachment's bytes from its sender over the file plane.
|
/// Fetch a chat attachment's bytes from its sender over the file plane.
|
||||||
pub async fn fetch_attachment(
|
pub async fn fetch_attachment(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
+30
-3
@@ -152,9 +152,10 @@ pub enum RoomEvent {
|
|||||||
author: EndpointId,
|
author: EndpointId,
|
||||||
skew_ms: i64,
|
skew_ms: i64,
|
||||||
},
|
},
|
||||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
/// A peer sent a room text-chat message. Carries the sender's id, the
|
||||||
/// display name (embedded so it shows even without a presence entry), the
|
/// sender-CLAIMED display name (untrusted; the core replaces it with the
|
||||||
/// text, and a sender-stamped millisecond timestamp.
|
/// roster-bound name before the UI sees it — chat-hardening Phase 2), the
|
||||||
|
/// text, and the signed envelope timestamp (display only, never ordering).
|
||||||
ChatMessage {
|
ChatMessage {
|
||||||
from: EndpointId,
|
from: EndpointId,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -184,6 +185,32 @@ pub enum ConnEvent {
|
|||||||
Left(EndpointId),
|
Left(EndpointId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owned snapshot of a peer's *selected* QUIC path (the one currently carrying
|
||||||
|
/// application data), taken from the live audio connection for the UI's
|
||||||
|
/// connection-transparency badge. Counters are cumulative for the path's
|
||||||
|
/// lifetime; rate/loss derivation over a poll window happens in
|
||||||
|
/// `core::connstats` (which also detects path switches via `remote_addr`).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PathSnapshot {
|
||||||
|
/// True when the path runs through a relay server, false for a direct
|
||||||
|
/// (holepunched or local) IP path.
|
||||||
|
pub is_relay: bool,
|
||||||
|
/// The path's remote transport address: `ip:port` for a direct path, the
|
||||||
|
/// relay URL for a relayed one.
|
||||||
|
pub remote_addr: String,
|
||||||
|
/// Current QUIC round-trip-time estimate for the path.
|
||||||
|
pub rtt: std::time::Duration,
|
||||||
|
/// Cumulative bytes sent in UDP datagrams on the path.
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
/// Cumulative bytes received in UDP datagrams on the path.
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
/// Cumulative UDP datagrams sent on the path (the loss denominator: for our
|
||||||
|
/// small voice frames these map ~1:1 to QUIC packets).
|
||||||
|
pub tx_datagrams: u64,
|
||||||
|
/// Cumulative packets detected lost on the path.
|
||||||
|
pub lost_packets: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct PeerSpeakTicket {
|
pub struct PeerSpeakTicket {
|
||||||
pub host_addr: iroh::EndpointAddr,
|
pub host_addr: iroh::EndpointAddr,
|
||||||
|
|||||||
+25
-1
@@ -80,6 +80,14 @@ pub enum Sound {
|
|||||||
MicToggle,
|
MicToggle,
|
||||||
/// Reconnect failed / peer evicted.
|
/// Reconnect failed / peer evicted.
|
||||||
ReconnectFailed,
|
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 {
|
impl Sound {
|
||||||
@@ -93,10 +101,14 @@ impl Sound {
|
|||||||
Sound::SelfLeave,
|
Sound::SelfLeave,
|
||||||
Sound::MicToggle,
|
Sound::MicToggle,
|
||||||
Sound::ReconnectFailed,
|
Sound::ReconnectFailed,
|
||||||
|
Sound::ChatSent,
|
||||||
|
Sound::ChatReceived,
|
||||||
|
Sound::ContactOnline,
|
||||||
|
Sound::ContactOffline,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Number of distinct notification events.
|
/// 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`.
|
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
|
||||||
fn index(self) -> usize {
|
fn index(self) -> usize {
|
||||||
@@ -109,6 +121,10 @@ impl Sound {
|
|||||||
Sound::SelfLeave => 5,
|
Sound::SelfLeave => 5,
|
||||||
Sound::MicToggle => 6,
|
Sound::MicToggle => 6,
|
||||||
Sound::ReconnectFailed => 7,
|
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::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
|
||||||
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
||||||
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.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::SelfLeave => "self-leave",
|
||||||
Sound::MicToggle => "mic-toggle",
|
Sound::MicToggle => "mic-toggle",
|
||||||
Sound::ReconnectFailed => "reconnect-failed",
|
Sound::ReconnectFailed => "reconnect-failed",
|
||||||
|
Sound::ChatSent => "chat-sent",
|
||||||
|
Sound::ChatReceived => "chat-received",
|
||||||
|
Sound::ContactOnline => "contact-online",
|
||||||
|
Sound::ContactOffline => "contact-offline",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+419
-120
@@ -3,26 +3,38 @@
|
|||||||
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
|
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
|
||||||
//! untrusted and spoofable, yet they're rendered directly in the roster. This
|
//! 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
|
//! 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
|
//! gets a safe value (security finding S4). Chat text policy ([`sanitize_chat`],
|
||||||
//! the UI layer (`app::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
|
/// 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
|
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
|
||||||
/// the roster readable.
|
/// the roster readable.
|
||||||
pub const NAME_MAX_CHARS: usize = 48;
|
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
|
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
|
||||||
/// rendered name even though they are NOT caught by [`char::is_control`]:
|
/// rendered name even though they are NOT caught by [`char::is_control`]:
|
||||||
/// bidirectional overrides/isolates (text-direction spoofing) and
|
/// bidirectional overrides/isolates (text-direction spoofing) and
|
||||||
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
|
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
|
||||||
/// explicitly so the sanitizer stays dependency-free (std exposes no category
|
/// explicitly so the sanitizer stays dependency-free (std exposes no category
|
||||||
/// query). Stripped outright rather than replaced.
|
/// query). Stripped outright rather than replaced.
|
||||||
fn is_spoofing_format_char(c: char) -> bool {
|
pub(crate) fn is_spoofing_format_char(c: char) -> bool {
|
||||||
matches!(c,
|
is_bidi_override_char(c)
|
||||||
|
|| matches!(c,
|
||||||
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
|
'\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{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
|
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -73,15 +85,98 @@ pub fn sanitize_game_label(input: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A piece of a chat message after URL detection: literal text or a link.
|
/// Max characters kept for a single chat message after sanitizing.
|
||||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
pub const CHAT_MSG_MAX_CHARS: usize = 2000;
|
||||||
pub enum Segment {
|
|
||||||
/// Plain text to render as-is.
|
/// Max UTF-8 bytes kept for a single chat message, enforced alongside
|
||||||
Text(String),
|
/// [`CHAT_MSG_MAX_CHARS`] (2,000 four-byte scalars would otherwise reach 8,000
|
||||||
/// A detected URL to render as a clickable link (also its href).
|
/// bytes). This is also the ingress bound: signed peers never produce more, so
|
||||||
Link(String),
|
/// 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
|
/// 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).
|
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
|
||||||
fn is_url_trailing_punct(c: char) -> bool {
|
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.
|
/// scanning only on char boundaries so slicing is always safe.
|
||||||
fn find_scheme(s: &str) -> Option<usize> {
|
fn find_scheme(s: &str) -> Option<usize> {
|
||||||
s.char_indices().find_map(|(i, _)| {
|
s.char_indices().find_map(|(i, _)| {
|
||||||
let tail = &s[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
|
/// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
|
||||||
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs
|
/// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
|
||||||
/// are treated as links, each ending at the first whitespace, with trailing prose
|
/// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
|
||||||
/// punctuation peeled back into the following text. Concatenating every segment's
|
/// username/password syntax (`http://user@host` reads as a credential but is a
|
||||||
/// inner string reproduces the input exactly (no characters added or dropped), so
|
/// classic destination-spoof — such text stays plain, never clickable).
|
||||||
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format
|
pub fn is_safe_web_url(s: &str) -> bool {
|
||||||
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable.
|
let Ok(u) = url::Url::parse(s) else {
|
||||||
pub fn linkify(input: &str) -> Vec<Segment> {
|
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 out = Vec::new();
|
||||||
let mut rest = input;
|
let mut base = 0usize;
|
||||||
while !rest.is_empty() {
|
while out.len() < CHAT_MSG_MAX_LINKS {
|
||||||
let Some(start) = find_scheme(rest) else {
|
let Some(start) = find_scheme(&text[base..]) else {
|
||||||
out.push(Segment::Text(rest.to_string()));
|
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if start > 0 {
|
let run_start = base + start;
|
||||||
out.push(Segment::Text(rest[..start].to_string()));
|
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());
|
out
|
||||||
let candidate = &after[..end];
|
}
|
||||||
// Peel trailing punctuation back out of the link.
|
|
||||||
let url = candidate.trim_end_matches(is_url_trailing_punct);
|
/// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
|
||||||
out.push(Segment::Link(url.to_string()));
|
/// output. Concatenating the slices reproduces `text` exactly (purely a
|
||||||
// Continue past just the URL; any peeled punctuation + the rest (incl. the
|
/// presentational split — no characters added or dropped). Borrows, so a redraw
|
||||||
// whitespace) is reconsidered as ordinary text on the next iteration.
|
/// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
|
||||||
rest = &after[url.len()..];
|
/// 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
|
out
|
||||||
}
|
}
|
||||||
@@ -215,93 +353,199 @@ mod tests {
|
|||||||
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
|
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.
|
#[test]
|
||||||
fn reassemble(segs: &[Segment]) -> String {
|
fn chat_keeps_ordinary_text_and_unicode() {
|
||||||
segs.iter()
|
assert_eq!(sanitize_chat("hello world"), "hello world");
|
||||||
.map(|s| match s {
|
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
|
||||||
Segment::Text(t) | Segment::Link(t) => t.as_str(),
|
// 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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn linkify_plain_text_has_no_links() {
|
fn url_policy_accepts_only_wellformed_web_urls() {
|
||||||
let segs = linkify("just a normal message, nothing here");
|
for ok in [
|
||||||
assert_eq!(
|
"http://example.com",
|
||||||
segs,
|
"https://a.test/path?q=1&w=2",
|
||||||
vec![Segment::Text("just a normal message, nothing here".into())]
|
"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]
|
#[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!(
|
assert_eq!(
|
||||||
linkify("see http://example.com now"),
|
links("a http://one.com b https://two.com c"),
|
||||||
vec![
|
["http://one.com", "https://two.com"]
|
||||||
Segment::Text("see ".into()),
|
|
||||||
Segment::Link("http://example.com".into()),
|
|
||||||
Segment::Text(" now".into()),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
// Sentence-capitalized scheme still detected; href = the displayed slice.
|
||||||
linkify("https://a.test/path?q=1"),
|
assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
|
||||||
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() {
|
|
||||||
for msg in [
|
for msg in [
|
||||||
"",
|
"",
|
||||||
"no urls at all",
|
"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!",
|
"pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
|
||||||
"weird))) http://c.com]]] tail",
|
"weird))) http://c.com]]] tail",
|
||||||
"unicode 世界 http://d.com/路径 more 世界",
|
"unicode 世界 http://d.com/路径 more 世界",
|
||||||
|
"bad http:// and http://user@x.com around https://ok.org here",
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(reassemble(msg), msg, "roundtrip failed for {msg:?}");
|
||||||
reassemble(&linkify(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+275
-30
@@ -21,6 +21,8 @@ use std::time::Duration;
|
|||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
|
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||||
|
|
||||||
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
||||||
/// points elsewhere.
|
/// points elsewhere.
|
||||||
const PIXELPASS_BIN: &str = "pixelpass";
|
const PIXELPASS_BIN: &str = "pixelpass";
|
||||||
@@ -139,7 +141,11 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
|
|||||||
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
||||||
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
||||||
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
|
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
|
||||||
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
pub fn host_args(
|
||||||
|
audio_app: Option<&str>,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
|
) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
"--output".to_string(),
|
"--output".to_string(),
|
||||||
@@ -149,9 +155,44 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
|||||||
args.push(format!("--app={name}"));
|
args.push(format!("--app={name}"));
|
||||||
args.push("--strict-audio".to_string());
|
args.push("--strict-audio".to_string());
|
||||||
}
|
}
|
||||||
|
if quality != ShareQuality::Auto {
|
||||||
|
args.push(format!("--quality={}", pixelpass_quality(quality)));
|
||||||
|
}
|
||||||
|
if let Some(height) = settings.max_height {
|
||||||
|
args.push(format!("--max-height={height}"));
|
||||||
|
}
|
||||||
|
if let Some(mbps) = settings.bitrate_mbps {
|
||||||
|
args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
|
||||||
|
}
|
||||||
|
if let Some(fps) = settings.framerate {
|
||||||
|
args.push(format!("--framerate={fps}"));
|
||||||
|
}
|
||||||
|
if settings.force_software_encode {
|
||||||
|
args.push("--no-hwencode".to_string());
|
||||||
|
}
|
||||||
|
if let Some(max) = settings.max_viewers {
|
||||||
|
args.push(format!("--max-viewers={max}"));
|
||||||
|
}
|
||||||
|
args.extend(split_extra_args(&settings.extra_host_args));
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
|
||||||
|
match quality {
|
||||||
|
ShareQuality::Auto => "auto",
|
||||||
|
ShareQuality::Low => "low",
|
||||||
|
ShareQuality::Medium => "medium",
|
||||||
|
ShareQuality::High => "high",
|
||||||
|
ShareQuality::Source => "source",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
|
||||||
|
/// not depend on a shell lexer, so quoted values are not interpreted here.
|
||||||
|
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
|
||||||
|
raw.split_whitespace().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
||||||
/// trim, reject empty / overlong, and reject names carrying control characters
|
/// trim, reject empty / overlong, and reject names carrying control characters
|
||||||
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
||||||
@@ -320,15 +361,26 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
|||||||
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
||||||
/// drained in a background task so a full pipe can't stall the host. We do
|
/// drained in a background task so a full pipe can't stall the host. We do
|
||||||
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
/// not pass encode/viewer overrides unless the local settings explicitly ask for
|
||||||
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
/// them, so pixelpass keeps its own defaults in the common case.
|
||||||
pub async fn spawn_host(
|
pub async fn spawn_host(
|
||||||
bin: &Path,
|
bin: &Path,
|
||||||
audio_app: Option<&str>,
|
audio_app: Option<&str>,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
) -> std::io::Result<(Child, String)> {
|
) -> std::io::Result<(Child, String)> {
|
||||||
|
let args = host_args(audio_app, settings, quality);
|
||||||
|
// 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(" ")
|
||||||
|
));
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.args(host_args(audio_app))
|
.args(&args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
// Capture stderr (not null): pixelpass prints its startup precondition
|
// Capture stderr (not null): pixelpass prints its startup precondition
|
||||||
@@ -428,10 +480,14 @@ pub fn pixelpass_failure_detail(stderr: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
||||||
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
/// stream in a local player (mpv/VLC in the configured order, then fallback).
|
||||||
/// child so the caller can kill it on room-leave; it also self-exits when the
|
/// Returns the live viewer child so the caller can kill it on room-leave; it also
|
||||||
/// player window closes (its tunnel ends).
|
/// self-exits when the player window closes (its tunnel ends).
|
||||||
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
pub async fn spawn_viewer(
|
||||||
|
bin: &Path,
|
||||||
|
ticket: &str,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
) -> std::io::Result<Child> {
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.args(viewer_args(ticket))
|
.args(viewer_args(ticket))
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
@@ -465,7 +521,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = launch_player(&url) {
|
if let Err(e) = launch_player(&url, settings) {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
@@ -547,23 +603,32 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
|
/// Open the viewer stream URL in a media player, then fall back to vlc. The
|
||||||
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
|
/// player is reaped in a background task so it doesn't linger as a zombie when
|
||||||
/// background task so it doesn't linger as a zombie when its window closes.
|
/// its window closes.
|
||||||
fn launch_player(url: &str) -> std::io::Result<()> {
|
///
|
||||||
const MPV_ARGS: &[&str] = &[
|
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
|
||||||
"--profile=low-latency",
|
/// passes mpv's `--untimed`, displaying frames as soon as they decode so a live
|
||||||
"--untimed",
|
/// share cannot accumulate several seconds in the reliable QUIC/TCP/player
|
||||||
"--hwdec=auto",
|
/// buffer chain. Smooth leaves timestamp pacing enabled, which keeps a shared
|
||||||
"--audio-buffer=0.2",
|
/// video's audio and video synchronized at the cost of extra live latency.
|
||||||
"--demuxer-max-bytes=2M",
|
/// Hardware decoding remains opt-in: forcing `--hwdec=auto` froze some viewers
|
||||||
"--demuxer-readahead-secs=0.5",
|
/// on frame 1 while audio kept playing.
|
||||||
];
|
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
||||||
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
|
let mpv_args = mpv_args(settings);
|
||||||
|
let vlc_args = vlc_args(settings);
|
||||||
|
let first = match settings.player {
|
||||||
|
SharePlayer::Mpv => ("mpv", &mpv_args),
|
||||||
|
SharePlayer::Vlc => ("vlc", &vlc_args),
|
||||||
|
};
|
||||||
|
let second = match settings.player {
|
||||||
|
SharePlayer::Mpv => ("vlc", &vlc_args),
|
||||||
|
SharePlayer::Vlc => ("mpv", &mpv_args),
|
||||||
|
};
|
||||||
|
|
||||||
let child = match spawn_player("mpv", MPV_ARGS, url) {
|
let child = match spawn_player(first.0, first.1, url) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| {
|
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::NotFound,
|
||||||
"no media player found — install mpv or vlc to watch screen shares",
|
"no media player found — install mpv or vlc to watch screen shares",
|
||||||
@@ -577,7 +642,70 @@ fn launch_player(url: &str) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> {
|
pub fn mpv_args(settings: &ScreenShareSettings) -> 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. If
|
||||||
|
// mpv paces a live stream even slightly slower than capture, those
|
||||||
|
// downstream buffers retain stale frames and latency grows into
|
||||||
|
// seconds. Immediate presentation keeps this posture at the live
|
||||||
|
// edge; Smooth deliberately retains timestamp pacing for A/V sync.
|
||||||
|
args.push("--untimed".to_string());
|
||||||
|
args.push("--audio-buffer=0.2".to_string());
|
||||||
|
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||||
|
}
|
||||||
|
ShareBuffering::Smooth => {
|
||||||
|
args.push("--cache=yes".to_string());
|
||||||
|
args.push("--demuxer-readahead-secs=2".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
|
||||||
|
if settings.hardware_decode {
|
||||||
|
args.push("--hwdec=auto".to_string());
|
||||||
|
}
|
||||||
|
args.extend(split_extra_args(&settings.extra_mpv_args));
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
|
||||||
|
/// that map cleanly onto its option set: the buffering posture (network/live
|
||||||
|
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
|
||||||
|
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
|
||||||
|
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
|
||||||
|
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
|
||||||
|
/// them as mpv-only. Pure: no I/O.
|
||||||
|
///
|
||||||
|
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
|
||||||
|
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
|
||||||
|
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
|
||||||
|
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
|
||||||
|
/// `--hwdec=auto`.
|
||||||
|
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||||
|
let caching_ms = match settings.buffering {
|
||||||
|
ShareBuffering::LowLatency => 200,
|
||||||
|
ShareBuffering::Smooth => 1500,
|
||||||
|
};
|
||||||
|
let hw = if settings.hardware_decode {
|
||||||
|
"--avcodec-hw=any"
|
||||||
|
} else {
|
||||||
|
"--avcodec-hw=none"
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
format!("--network-caching={caching_ms}"),
|
||||||
|
format!("--live-caching={caching_ms}"),
|
||||||
|
hw.to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
|
||||||
|
// Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
|
||||||
|
// --avcodec-hw) so a field log can confirm the viewer settings reached the
|
||||||
|
// player. The `url` is omitted deliberately — it is the local stream address
|
||||||
|
// and is not needed to verify the flags. Logged on each attempt, so a
|
||||||
|
// fallback from the preferred player to the other one is visible too.
|
||||||
|
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||||
Command::new(bin)
|
Command::new(bin)
|
||||||
.args(args)
|
.args(args)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
@@ -621,7 +749,11 @@ mod tests {
|
|||||||
fn host_args_without_app_shares_whole_desktop() {
|
fn host_args_without_app_shares_whole_desktop() {
|
||||||
// No app selected → no --app flag → pixelpass keeps its default
|
// No app selected → no --app flag → pixelpass keeps its default
|
||||||
// (whole-desktop) audio capture.
|
// (whole-desktop) audio capture.
|
||||||
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
|
let settings = ScreenShareSettings::default();
|
||||||
|
assert_eq!(
|
||||||
|
host_args(None, &settings, ShareQuality::Auto),
|
||||||
|
vec!["--host", "--output", "json"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -629,8 +761,9 @@ mod tests {
|
|||||||
// The chosen app rides in the `--app=<name>` single-token form so a
|
// The chosen app rides in the `--app=<name>` single-token form so a
|
||||||
// name beginning with `-` can never be reparsed as a flag (A23), plus
|
// name beginning with `-` can never be reparsed as a flag (A23), plus
|
||||||
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
||||||
|
let settings = ScreenShareSettings::default();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
host_args(Some("Firefox")),
|
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
|
||||||
vec![
|
vec![
|
||||||
"--host",
|
"--host",
|
||||||
"--output",
|
"--output",
|
||||||
@@ -641,7 +774,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// The hyphen-leading name is still bound to --app as a single token;
|
// The hyphen-leading name is still bound to --app as a single token;
|
||||||
// --strict-audio is the trailing flag.
|
// --strict-audio is the trailing flag.
|
||||||
let args = host_args(Some("-rm -rf"));
|
let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
|
||||||
assert_eq!(args[3], "--app=-rm -rf");
|
assert_eq!(args[3], "--app=-rm -rf");
|
||||||
assert_eq!(args[4], "--strict-audio");
|
assert_eq!(args[4], "--strict-audio");
|
||||||
}
|
}
|
||||||
@@ -650,11 +783,123 @@ mod tests {
|
|||||||
fn host_args_blank_or_control_app_is_dropped() {
|
fn host_args_blank_or_control_app_is_dropped() {
|
||||||
// An empty / whitespace / control-laden selection is sanitized away,
|
// An empty / whitespace / control-laden selection is sanitized away,
|
||||||
// falling back to whole-desktop capture rather than a broken flag.
|
// falling back to whole-desktop capture rather than a broken flag.
|
||||||
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
|
let settings = ScreenShareSettings::default();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
host_args(Some("bad\nname")),
|
host_args(Some(" "), &settings, ShareQuality::Auto),
|
||||||
vec!["--host", "--output", "json"]
|
vec!["--host", "--output", "json"]
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
|
||||||
|
vec!["--host", "--output", "json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_apply_screen_share_settings_and_extra_args_last() {
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
bitrate_mbps: Some(5),
|
||||||
|
framerate: Some(60),
|
||||||
|
max_height: Some(1080),
|
||||||
|
max_viewers: Some(4),
|
||||||
|
force_software_encode: true,
|
||||||
|
extra_host_args: "--relay https://relay.example --verbose".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("Firefox"), &settings, ShareQuality::High),
|
||||||
|
vec![
|
||||||
|
"--host",
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"--app=Firefox",
|
||||||
|
"--strict-audio",
|
||||||
|
"--quality=high",
|
||||||
|
"--max-height=1080",
|
||||||
|
"--bitrate=5000",
|
||||||
|
"--framerate=60",
|
||||||
|
"--no-hwencode",
|
||||||
|
"--max-viewers=4",
|
||||||
|
"--relay",
|
||||||
|
"https://relay.example",
|
||||||
|
"--verbose",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpv_args_default_matches_low_latency_software_decode() {
|
||||||
|
assert_eq!(
|
||||||
|
mpv_args(&ScreenShareSettings::default()),
|
||||||
|
vec![
|
||||||
|
"--profile=low-latency",
|
||||||
|
"--untimed",
|
||||||
|
"--audio-buffer=0.2",
|
||||||
|
"--demuxer-readahead-secs=0.5",
|
||||||
|
"--demuxer-max-bytes=2M",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
hardware_decode: true,
|
||||||
|
buffering: ShareBuffering::Smooth,
|
||||||
|
cache_mb: 16,
|
||||||
|
extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mpv_args(&settings),
|
||||||
|
vec![
|
||||||
|
"--cache=yes",
|
||||||
|
"--demuxer-readahead-secs=2",
|
||||||
|
"--demuxer-max-bytes=16M",
|
||||||
|
"--hwdec=auto",
|
||||||
|
"--no-osc",
|
||||||
|
"--vd-lavc-threads=2",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vlc_args_default_disables_hardware_decode() {
|
||||||
|
// The A-bug fix default (hardware_decode = false) must reach VLC too:
|
||||||
|
// VLC hardware-decodes by default, so without an explicit
|
||||||
|
// `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
|
||||||
|
// the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
|
||||||
|
assert_eq!(
|
||||||
|
vlc_args(&ScreenShareSettings::default()),
|
||||||
|
vec![
|
||||||
|
"--network-caching=200",
|
||||||
|
"--live-caching=200",
|
||||||
|
"--avcodec-hw=none",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vlc_args_smooth_buffering_and_hwdecode() {
|
||||||
|
// Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
|
||||||
|
// buffering raises the network/live caches. cache_mb / extra_mpv_args are
|
||||||
|
// mpv-only and must NOT leak into the VLC argv.
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
hardware_decode: true,
|
||||||
|
buffering: ShareBuffering::Smooth,
|
||||||
|
cache_mb: 16,
|
||||||
|
extra_mpv_args: "--no-osc".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
vlc_args(&settings),
|
||||||
|
vec![
|
||||||
|
"--network-caching=1500",
|
||||||
|
"--live-caching=1500",
|
||||||
|
"--avcodec-hw=any",
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
|
||||||
|
/// selection) from clipboard text before it is pasted. Shared by the
|
||||||
|
/// right-click menu Paste and the middle-click PRIMARY paste.
|
||||||
|
pub fn sanitize_clip(raw: &str) -> String {
|
||||||
|
raw.chars().filter(|c| !c.is_control()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_all_range(value: &str) -> (usize, usize) {
|
pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||||
let value = text_input::Value::new(value);
|
let value = text_input::Value::new(value);
|
||||||
|
|
||||||
@@ -362,6 +369,48 @@ where
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
|
||||||
|
// base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
|
||||||
|
// selection, so without this the common "select text, middle-click to
|
||||||
|
// paste" workflow does nothing on X11.
|
||||||
|
let middle_click_on_input = matches!(
|
||||||
|
event,
|
||||||
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
|
||||||
|
) && cursor.is_over(layout.bounds());
|
||||||
|
|
||||||
|
if middle_click_on_input && !self.locked {
|
||||||
|
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());
|
||||||
|
|
||||||
|
if !clip.is_empty() {
|
||||||
|
let value = text_input::Value::new(&self.value);
|
||||||
|
let input_state = tree.children[0]
|
||||||
|
.state
|
||||||
|
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
||||||
|
let (start, end) = match input_state.cursor().state(&value) {
|
||||||
|
text_input::cursor::State::Index(index) => {
|
||||||
|
let index = index.min(value.len());
|
||||||
|
(index, index)
|
||||||
|
}
|
||||||
|
text_input::cursor::State::Selection { start, end } => {
|
||||||
|
normalized_range(&value, start, end)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let edit = paste(&self.value, start, end, &clip);
|
||||||
|
input_state.move_cursor_to(edit.cursor);
|
||||||
|
|
||||||
|
if let Some(on_paste) = &self.on_paste {
|
||||||
|
shell.publish(on_paste.as_ref()(edit.value));
|
||||||
|
} else if let Some(on_input) = &self.on_input {
|
||||||
|
shell.publish(on_input.as_ref()(edit.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.capture_event();
|
||||||
|
shell.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Widget::update(
|
Widget::update(
|
||||||
&mut self.input,
|
&mut self.input,
|
||||||
&mut tree.children[0],
|
&mut tree.children[0],
|
||||||
@@ -717,12 +766,11 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Paste => {
|
MenuAction::Paste => {
|
||||||
let clip = clipboard
|
let clip = sanitize_clip(
|
||||||
|
&clipboard
|
||||||
.read(clipboard::Kind::Standard)
|
.read(clipboard::Kind::Standard)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default(),
|
||||||
.chars()
|
);
|
||||||
.filter(|c| !c.is_control())
|
|
||||||
.collect::<String>();
|
|
||||||
let edit = paste(self.value, start, end, &clip);
|
let edit = paste(self.value, start, end, &clip);
|
||||||
|
|
||||||
self.publish_paste(edit, shell);
|
self.publish_paste(edit, shell);
|
||||||
@@ -842,6 +890,16 @@ mod tests {
|
|||||||
assert_eq!(clip, None);
|
assert_eq!(clip, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_clip_strips_control_chars_keeps_text() {
|
||||||
|
// An X11 PRIMARY selection commonly carries a trailing newline.
|
||||||
|
assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
|
||||||
|
assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
|
||||||
|
// Non-control unicode is preserved.
|
||||||
|
assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
|
||||||
|
assert_eq!(sanitize_clip(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paste_replaces_selection_or_inserts_at_cursor() {
|
fn paste_replaces_selection_or_inserts_at_cursor() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const HIT_SEARCH_STEPS: usize = 24;
|
|||||||
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
|
// `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
|
// 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
|
// 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
|
// 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
|
// contain a newline. If that sanitizer ever starts preserving newlines, this
|
||||||
// widget's per-line offsets would stop being global and selection/copy across
|
// widget's per-line offsets would stop being global and selection/copy across
|
||||||
|
|||||||
@@ -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"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -244,6 +244,73 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connection transparency: over a real loopback link, `connection_stats()`
|
||||||
|
/// must report the peer's selected path as direct (relay disabled here), with
|
||||||
|
/// an IP remote address and counters that advance while audio flows — and the
|
||||||
|
/// `connstats::derive` seam must turn two such snapshots into badge info with
|
||||||
|
/// live rates.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_stats_report_a_direct_path_with_live_counters() {
|
||||||
|
let a = spawn_node().await;
|
||||||
|
let b = spawn_node().await;
|
||||||
|
|
||||||
|
a.lookup.add_endpoint_info(b.endpoint.addr());
|
||||||
|
b.lookup.add_endpoint_info(a.endpoint.addr());
|
||||||
|
|
||||||
|
let a_id = a.endpoint.id();
|
||||||
|
let b_id = b.endpoint.id();
|
||||||
|
a.transport.admit_audio_sender(b_id);
|
||||||
|
b.transport.admit_audio_sender(a_id);
|
||||||
|
|
||||||
|
// Keep B's receive path subscribed like production (drained implicitly).
|
||||||
|
let _b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
|
||||||
|
|
||||||
|
a.transport.connect_peer(b.endpoint.addr()).await;
|
||||||
|
b.transport.connect_peer(a.endpoint.addr()).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
let snap = |stats: Vec<(iroh::EndpointId, peerspeak::network::PathSnapshot)>| {
|
||||||
|
stats
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _)| *id == b_id)
|
||||||
|
.map(|(_, s)| s)
|
||||||
|
.expect("peer B should appear in A's connection stats")
|
||||||
|
};
|
||||||
|
let s1 = snap(a.transport.connection_stats());
|
||||||
|
assert!(!s1.is_relay, "loopback with relay disabled must be direct");
|
||||||
|
assert!(
|
||||||
|
s1.remote_addr.parse::<std::net::SocketAddr>().is_ok(),
|
||||||
|
"direct path address should be ip:port, got {}",
|
||||||
|
s1.remote_addr
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream real audio so the path counters move.
|
||||||
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
for seq in 0..25u32 {
|
||||||
|
a.transport.broadcast(packet(&mut enc, seq));
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let s2 = snap(a.transport.connection_stats());
|
||||||
|
assert!(s2.tx_bytes > s1.tx_bytes, "sent bytes should advance");
|
||||||
|
assert!(
|
||||||
|
s2.tx_datagrams > s1.tx_datagrams,
|
||||||
|
"sent datagrams should advance"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The derivation seam turns the two snapshots into live badge info.
|
||||||
|
let info = peerspeak::core::connstats::derive(Some(&s1), &s2, Duration::from_millis(200));
|
||||||
|
assert!(!info.relay);
|
||||||
|
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,
|
||||||
|
"audio was flowing, so the upstream rate must be non-zero"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Read datagrams off a raw connection until `target` arrive or the deadline
|
/// Read datagrams off a raw connection until `target` arrive or the deadline
|
||||||
/// passes, asserting each carries the 4-byte sequence header.
|
/// passes, asserting each carries the 4-byte sequence header.
|
||||||
async fn count_audio(conn: &Connection, target: u32, deadline: tokio::time::Instant) -> u32 {
|
async fn count_audio(conn: &Connection, target: u32, deadline: tokio::time::Instant) -> u32 {
|
||||||
@@ -503,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"
|
"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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user