The main webview retained `shell:allow-open` even though the frontend never
uses the shell plugin directly. File-manager opening already flows through the
`open_game_files` command, which checks catalog membership and containment.
Remove only the frontend permission. The Rust shell plugin and dependency stay
available for the checked backend open operation and bundled unrar sidecar.
Test Plan:
- `just build-fixture` -- passed, including the frontend build and Tauri
capability validation.
- `git diff --cached --check` -- passed.
Peer CLI scenarios, container names, and macvlan overrides were rendered into
shell source by Just before execution. Quoting the rendered text did not stop
command substitution or quote breaking.
Enable positional arguments for the variadic scenario recipe and consume all
caller-controlled values through exported shell variables. This preserves the
existing recipe interface, including multiple scenario arguments and network
overrides, while keeping their contents out of shell syntax.
Test Plan:
- `just --fmt --check` -- passed.
- `just --dry-run --no-deps peer-cli-tests S1 '$(printf SCENARIO_PROBE)' 'space value'` -- rendered only `"$@"`.
- `just --dry-run --no-deps peer-cli-run '$(printf NAME_PROBE)'` -- rendered only quoted `$NAME` uses.
- `just --dry-run --no-deps LANSPREAD_NET='$(printf NET_PROBE)' peer-cli-net` -- rendered only quoted network variables.
- `git diff --cached --check` -- passed.
The ignore file listed these three findings as deliberately not fixed. A
cross-review of the three parallel fix branches showed that the second
branch had small, safe fixes for each, and they have now been ported onto
this branch (justfile `set export`, fallible `setup_done_path`, bounded
wire collection deserialization). Remove their "not fixed" rationales
and move them to the fixed row of the summary table so the file again
accounts for all 35 findings accurately.
Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
Follow-up to EXP2-SEC-04 (wire game IDs). The wire validator rejected
separators, control characters and the `.`/`..` pseudo-components and
otherwise relied on the catalog lookup that follows every request. That
lookup is real, but it leaves a class of IDs on the wire that the catalog
itself would never publish: Windows device names (`CON`, `nul.txt`,
`com1`), trailing dots or spaces, and the Windows-reserved characters
`< > : " | ? *`. Rejecting them at the protocol boundary means a
filesystem-backed handler can never see one, whichever consumer is added
next.
`validate_game_id` now also calls
`lanspread_db::content_manifest::validate_portable_component`, the same
function the catalog uses to admit game IDs and that `path_validation.rs`
already reuses. Reusing it rather than copying the device-name table
(the parallel branch grew three private copies) guarantees the wire rule
can neither over-match nor drift: an ID the catalog accepts always
encodes. The existing `InvalidPathComponent` error variant is reused so
callers and logs are unchanged.
Tests add the newly rejected forms and a positive list of catalog-valid
IDs that must keep encoding: embedded dots and spaces (`game..v1
(final)`), `console.txt`, `com10` and a non-ASCII name. The fixture
catalogs and the peer test suite, which encode many request IDs, pass
unchanged.
Test plan:
- `cargo test -p lanspread-proto`: 24 passed.
- `cargo test -p lanspread-peer -p lanspread-peer-cli`: 491 + 15 + 21
passed.
- `cargo clippy -p lanspread-proto --all-targets -- -D warnings`: clean.
Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
Gemini audit finding NET-05 ("unbounded collection deserialization before
semantic validation", Low). `LibrarySnapshot::games` and
`CallToPlayAuthorSnapshot::events` were plain `Vec`s. Their semantic
validators reject more than 4,096 elements, but serde had already built
the complete vector by then, so a peer could make a receiver allocate up
to a full 8 MiB response frame's worth of parsed elements before the
limit was applied. The 64 KiB request bound already covers inbound
requests; this closes the same gap for responses a client accepts from a
peer it connected to.
Both fields now deserialize through a bounded visitor that keeps at most
`maximum + 1` elements and drains the remainder as `IgnoredAny` without
allocating. Keeping exactly one element past the limit is deliberate: the
existing `validate` methods still observe `len() > maximum` and report
`TooManyItems`, and `Response::decode` keeps leaving that judgement to
the caller so an invalid Call-to-Play domain does not discard a valid
library (and vice versa). Frames at or below the limit are byte-for-byte
unchanged, and the encoder still refuses to produce oversize frames.
Tests build oversize JSON by hand (the encoder cannot) and check that a
sequence four times the limit decodes to limit + 1 elements, that the
semantic validator then reports `TooManyItems`, and that a sequence
exactly at the limit is untouched. The existing domain-isolation test
asserts the truncated length as well.
Ported from the parallel security branch (lanspread2 commit 5139ec1),
which bundled it with the request frame bound this branch already has.
Test plan:
- `cargo test -p lanspread-proto`: 24 passed.
- `cargo clippy -p lanspread-proto --all-targets -- -D warnings`: clean.
Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
Scanner finding #12 ("state marker path escape"). The per-game state
helpers in `state_paths.rs` joined a raw game ID below
`<state_dir>/games/`. The public `setup_done_path` was therefore usable
with an absolute or parent-containing ID by an embedding caller, and the
legacy migration discovered IDs from directory names in the user's games
folder and joined them unconditionally. Every shipping caller today
validates its ID or takes it from the catalog, so this was a footgun
rather than an exploited hole, but the fix is small and removes the
reliance on every future caller remembering the rule.
Add `validate_game_state_id`, which rejects separators and NUL and then
delegates to `lanspread_db::content_manifest::validate_portable_component`
(the catalog's own rules: no `.`/`..`, no trailing dot or space, no control
or Windows-reserved characters, no Windows device names). Reusing the
catalog validator rather than a private copy guarantees that any ID the
catalog can publish is accepted here and that the two cannot drift apart.
`setup_done_path` now returns `eyre::Result<PathBuf>`; it is the only
state path the embedding application calls with an ID that may originate
from UI input. `launch_settings_applied_path` leaves the public API and
becomes `pub(crate)`; the two public launch-settings entry points
(`apply_launch_settings_once`, `mark_launch_settings_applied`) validate
the ID before any filesystem work. `game_state_dir` carries a
`debug_assert!` documenting the contract for internal callers without
turning a bad ID into a release-build panic; the migration test suite
exercises that assertion in debug builds.
Behaviour changes:
- Legacy migration logs a warning, counts a failure and leaves the legacy
marker in place for a games-folder directory whose name is not a
portable game ID, instead of creating state below it. A new test covers
a trailing-dot directory name.
- The Windows launcher ignores a run request whose ID `setup_done_path`
rejects, with a warning, mirroring the existing invalid-ID early return.
Tests cover catalog-valid IDs that must remain accepted (embedded dots,
spaces, `console.txt`, `com10`, non-ASCII) and unsafe IDs that must be
rejected (empty, `.`, `..`, separators, NUL, trailing dot or space,
device names, `a:b`).
This ports the fallible API from the parallel security branch (lanspread2
commits 4146a0e and 9f26c63) onto the validator this branch already
exports from `lanspread-db`.
Test plan:
- `cargo test -p lanspread-peer --lib`: 491 passed.
- `just clippy`: clean.
- On Windows, launch a game with a valid ID and confirm the setup marker
is still written under `<app-data>/games/<id>/setup_done`.
Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
Follow-up to the scanner finding #2 fix ("rejected bulk requests can
populate the persistent manifest cache"). The previous commit reordered
admission so the compact content index and local readiness are checked
before any manifest body is loaded. That relied on ordering alone: the
loader still called `CatalogBundle::manifest`, which reads and parses
the artifact from disk on a cache miss, and nothing in the test suite
proved that a rejected request leaves the cache untouched.
Switch the outbound admission loader to `cached_manifest`, which never
performs filesystem I/O. This is safe because every game that can pass
`can_serve_game` is catalog-eligible and its manifest was primed by
`prime_library_manifests` before the library revision that advertises it
became visible (server startup in `services/server.rs`, every library
scan publication in `handlers.rs`). A cache miss therefore means a
publication-ordering bug rather than a legitimate serve, and failing the
request closed with a logged error is the right outcome.
The admission tests now assert that a request with the wrong content
identity leaves `cached_manifest("game")` erroring, for both catalog
chunks and Stream Install, and prime the manifest explicitly before the
accepted-request assertions, mirroring what the server does. The
architecture document describes the cache-only serving path.
Behaviour visible to peers is unchanged for valid requests. Rejected
requests no longer cause a disk read under the admission lock.
This layers the `cached_manifest` switch and no-load assertions from the
parallel security branch (lanspread2 commit 4e0419a) onto the identity
gate introduced in 55fa494.
Test plan:
- `cargo test -p lanspread-peer --lib`: 488 passed.
- `just test`, `just clippy`, `cargo +nightly fmt --check` at the end of
the series.
Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
The Just recipes rendered LANSPREAD_GAMES_DIR, LANSPREAD_UNRAR, and recipe
parameters directly into shell command text. Double quotes around those
interpolations protected whitespace but still allowed command substitution and
other shell expansion before the called tool received its arguments.
Export Just variables and recipe parameters, then expand them only as quoted
shell parameters at execution time. Fixed repository paths remain Just
interpolations, while package roots, game IDs, output directories, and the
selected unrar executable are now passed as data. This preserves the existing
recipe interface and supports paths containing spaces or literal shell syntax.
Test Plan:
- `just --fmt --check` -- passed.
- dry-run with a command-substitution-shaped path stayed literal.
-- rendered a quoted shell variable and did not execute the substitution.
- `git diff --cached --check` -- passed.
Companion to the fix commits on this branch. Lists every finding from
the Gemini audit and the Codex scan that was not fixed or only partly
fixed, with the reasoning for each, anchored in what lanspread is: a
LAN-party launcher with an operator-published, BLAKE3-verified catalog
and intentionally anonymous requesters.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Bring ARCHITECTURE.md in line with the security fixes on this branch:
unicast-only mDNS candidates and the per-source-IP candidate budget,
source-IP binding of change hints, the split 64 KiB request / 8 MiB
response frame caps, and the `-ol-` plus link-audit rule for ordinary
`.eti` extraction.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding Codex #2 ("rejected bulk requests can populate
the persistent manifest cache"). `admit_outbound_transfer` loaded the
full catalog manifest for the requested game ID first and only then
compared the content ID and checked whether the game is locally
serveable. Because manifests are cached for the life of the process,
one anonymous LAN client could make a node parse and retain the entire
catalog's manifest corpus with requests for games it does not even
have, and every such request paid a disk read under the admission lock.
The catalog already exposes a compact content index that answers
identity and streamed-install support without I/O. Admission now
checks that index and in-memory local readiness first; only requests
that pass both load the manifest. Accepted requests behave exactly as
before, including the streamed-install support check.
Test plan: `just test`. Manual: with two peer-cli containers, chunk
downloads and Stream Install still complete; a request naming an
unknown game ID or wrong content ID is declined with the same log
message as before.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding SEC-IPC-01 (parameter part). The username is
passed to game_setup/game_start/server_start batch scripts as a quoted
`cmd.exe` argument. Quoting protects the launcher's own command line,
but batch scripts expand `%~4` textually into their own statements, so
a name such as `foo & calc` would run `calc` from `set NAME=%~4`. Since
the setup script runs elevated, that matters even though the value is
the local user's own input.
`sanitize_username` previously removed control characters, `"` and
`%`; it now also removes `& | < > ^`. Spaces, punctuation such as `!`
and non-ASCII letters remain allowed so ordinary gamer tags are not
mangled. The audit's stricter `[A-Za-z0-9_-]` allowlist was rejected
for that reason.
The elevated execution of catalog scripts itself is intentional: the
shared games need administrator setup and the archives that carry the
scripts are BLAKE3-verified against the bundled catalog.
Test plan: `just test` (extended sanitizer test).
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding Codex #14 ("cross-author event-ID collisions can
suppress Call-to-Play entries"). The Rust side guarantees that an event
nonce is unique within one authenticated author's history and preserves
`author_id` on every projected event, but the frontend reducer
deduplicated the merged view with `new Map(events.map(e => [e.id, e]))`
and tracked chat messages by `event.id` alone. A peer could therefore
publish, say, a Respond event reusing the nonce of another user's
Create event and make that call vanish from every viewer, or shadow
other users' chat messages.
`eventKeyOf` now builds `author_id + NUL + id` and is used for view
deduplication, event ordering ties, message deduplication and the
message id that CtpChat uses as its React key. Nomination ids
(`call_id`) were already creator scoped and are unchanged.
Test plan: `just frontend-test`. The new test feeds a Create from
Alice and a Respond from Bob sharing one nonce and expects both to
apply, then two same-nonce messages from different authors and expects
two distinct messages.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding SEC-IPC-02. `tauri.conf.json` set `"csp": null`,
which disables Tauri's CSP injection entirely. The audit found no
script-injection route in the frontend, so this is defense in depth:
should an XSS ever land through peer-supplied text, a CSP stops it from
loading remote scripts, exfiltrating over fetch/WebSocket or framing the
app, and confines IPC to Tauri's own channel.
Production policy (`csp`):
- default/script-src 'self': only the bundled Vite output runs. Tauri
adds hashes for the init scripts it injects.
- style-src 'self' 'unsafe-inline' plus fonts.googleapis.com: React
inline `style` props and the Bebas Neue stylesheet that index.html
already links.
- font-src 'self' data: fonts.gstatic.com: the font files behind that
stylesheet.
- img-src 'self' data: asset: http://asset.localhost: thumbnails arrive
as base64 data URLs from get_game_thumbnail.
- connect-src ipc: http://ipc.localhost: Tauri does not append these
itself; without them every `invoke` would be blocked.
- object-src/frame-src/form-action 'none', base-uri 'none'.
Development policy (`devCsp`): Vite's dev server injects the React
refresh preamble as an inline script and needs eval and a WebSocket to
localhost:1420 for HMR, so `just run` uses a permissive policy that
still forbids frames, plugins and form submission.
Verification here was limited to a static check: the production bundle
built by `deno task build` contains only external module scripts and
stylesheets, and `cargo tauri` parses the new config. The policy has
not been exercised in a running webview on this machine; if the app
shows a blank window or missing fonts/thumbnails after this change,
the WebView console will name the blocked directive.
Test plan: `just run` (dev) and `just build` then launch the binary;
confirm the library renders, thumbnails and the display font load,
IPC-backed actions (settings, log windows, Call to Play) work, and the
webview console shows no CSP violations.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding SEC-DB-01. The three read-only opens of the
catalog `game.db` (runtime bundle loader, legacy ETI reader and the
catalog publisher) set only `read_only(true)`. SQLite still honours
schema-embedded SQL in that mode: triggers, views, CHECK constraints
and expression indexes may call functions with side effects or virtual
tables unless `trusted_schema` is off.
`harden_read_only_catalog_options` now applies `trusted_schema = OFF`
and `cell_size_check = ON` to those connections. The database is a
bundled application resource, not a remote input, so this is defense in
depth against a corrupted or tampered bundle; it has no effect on the
parameterised queries the code runs.
Test plan: `just test` (the compat tests open real fixture databases
through the hardened options).
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding EXP2-SEC-06. When no explicit state directory,
`LANSPREAD_STATE_DIR`, `HOME` or `USERPROFILE` was available,
`resolve_state_dir` silently used `<temp_dir>/lanspread`. On a
multi-user machine that is a predictable, world-writable location:
another local user could pre-create it and then read or replace the
Ed25519 peer identity and the download ownership journals stored there.
Both shipping callers always provide a directory (the Tauri app passes
its app-data path, the CLI its `--state-dir`), so the fallback was only
reachable in unusual environments. Rather than derive a UID-specific
temp path, peer startup now returns an error naming the accepted
sources. This is the same fail-closed stance the codebase already takes
for a malformed sharing policy.
Test plan: `just test`. Manually, `LANSPREAD_STATE_DIR= HOME=
lanspread-peer-cli ...` without `--state-dir` must refuse to start with
a clear message; normal `just run` and `just peer-cli-run` are
unaffected.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding EXP2-SEC-03. `path_validation.rs` guarded
against traversal, UNC prefixes, drive letters and symlink escapes, but
unlike the catalog validators in lanspread-db it did not reject Windows
device names (CON, NUL, COM1..9, LPT1..9), components with a trailing
dot or space, reserved characters (`<>:"|?*`), or control characters.
On Windows, opening `NUL.txt` talks to a device and `file.txt.` is
silently rewritten to `file.txt`, so such names must never reach the
filesystem.
The catalog component validator is now exported from lanspread-db as
`validate_portable_component` and applied to every normal component in
`validate_relative_path`. The only current caller is Stream Install's
staging-path resolution, whose inputs are already canonical catalog
paths, so this changes nothing for valid archives; it removes a
divergence between two validators that are supposed to agree.
Test plan: `just test` (new cases cover device names in any position,
trailing dot/space, a reserved character and a control character, and
confirm `console.txt` and `com10.txt` stay valid).
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit findings EXP2-SEC-01, SEC-IPC-04 and Codex #3 (symlink
redirection through externally extracted archives).
The ordinary install path hands every root `.eti` archive to an external
`unrar x` process and promotes the resulting staging directory to
`local/` as soon as the extractor exits 0. Nothing inspected what unrar
materialised. A symbolic link (or, on Windows, a junction) inside an
archive would survive promotion and later redirect the launch-time
settings rewrite in `apply_launch_settings_once`, the uninstall path,
or any script the game ships. The archives themselves are BLAKE3
verified against the bundled catalog before they can be installed, so a
hostile link would have to be published by the catalog operator; this
is defense in depth rather than a live remote exploit.
Two independent layers now guard promotion:
- Both `unrar` invocations (Tauri sidecar and peer-cli external
unpacker) pass `-ol-`, which makes unrar 7.x skip symbolic-link
entries entirely. The bundled 7.10 sidecar was checked against a
fixture archive.
- `install_inner`/`update_inner` walk the staging tree without
following links and refuse to promote it if any entry is a symlink or
(on Windows) carries the reparse-point attribute. The normal rollback
then removes staging and clears the install intent.
The audit's `-sl-` suggestion does not exist in unrar; `-sl<size>` is a
size filter. Post-extraction digest verification of every extracted
file remains out of scope for the ordinary path; Stream Install already
verifies each output entry.
Test plan: `just test` (new unix test installs with a fake unpacker
that plants a symlink and asserts install fails, `local/` is absent and
the intent is cleared; the peer-cli controlled-unrar test checks the
new argument position). Manual: install a fixture game via peer-cli.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit findings NET-01 (partial) and Codex #9 ("unauthenticated
hints can make the victim pull arbitrary known peers").
Inbound QUIC connections are intentionally anonymous: only the responder
is authenticated, so any LAN host can open a stream and send
`LibraryChanged`/`CallToPlayChanged` hints naming any `claimed_peer_id`.
If the claimed peer was known and the forged session or revision did
not match the cached snapshot, state sync scheduled a full pinned Hello
pull to that peer. Sending one small forged hint to every node on the
LAN therefore made all of them pull a victim's complete snapshot at
once (reflected amplification), bounded only by the 5-second per-peer
coalesce window.
Full mutual TLS would bind hints to a verified identity but is a larger
protocol change than this application warrants. Instead the hint now
carries the source IP of the anonymous connection, and
`hint_requires_pull_from_snapshot` discards any hint whose source IP
differs from the address at which the claimed peer was last
authenticated. On a LAN a QUIC connection cannot be established from a
spoofed IP, so a third host can no longer select which peer this node
pulls. A genuine peer whose address changed loses only the hint fast
path; mDNS rediscovery and pinned liveness reconciliation still pick it
up.
`PeerEndpointGeneration::for_tests` is added under cfg(test) so unit
tests can build a `PeerRevisionSnapshot`.
Test plan: `just test`. The new test accepts a hint from the peer's
address, rejects the same hint from another IP or with no address, and
keeps the revision comparison for matching sources. Manual: with two
peer-cli containers, adding a game on one still triggers the other to
refresh its library promptly.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit findings NET-04 and Codex #15 ("forged mDNS candidates
can monopolize discovery slots").
Discovery admits at most 64 active or cooling-down candidates, keyed by
claimed peer ID and full socket address. Both keys are attacker chosen:
one LAN host can advertise 64 distinct peer IDs on 64 ports within
milliseconds, fill every slot, and repeat the burst every 5 seconds so
that every genuinely new peer is dropped with "recent-attempt limit is
full". The audit proposed FIFO eviction instead, but that would let the
same flood evict legitimate candidates; the real asymmetry is that a
host can mint identities and ports cheaply but cannot mint IP addresses
without also answering QUIC on them.
Admission now additionally refuses a candidate when its source IP
already accounts for MAX_DISCOVERY_CANDIDATES_PER_SOURCE_IP (8)
entries across the active set and the unexpired cooldown list. A
flooding host can therefore occupy at most 8 of the 64 slots; other
hosts are unaffected. Eight is generous for the legitimate case of a
few peer instances on one machine.
Test plan: `just test`. The new unit test fills one IP's budget,
verifies other IPs are still admitted, and verifies the budget is
released after the cooldown. The pre-existing active-cap test now
spreads its 64 candidates over distinct hosts.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit findings NET-03 and Codex #6 ("control-frame prefixes
can reserve about 512 MiB across concurrent decoders").
Both directions of the control plane shared MAX_CONTROL_FRAME_BYTES
(8 MiB). That size exists for responses: a HelloSnapshot with 4096
library games and a maximal Call-to-Play author slice legitimately
approaches it. Requests are tiny; the largest possible GetGameFileChunk
with a 255-byte game ID and a 900-byte catalog path is under 2 KiB.
Yet every anonymous inbound stream was decoded with an 8 MiB
LengthDelimitedCodec, and tokio-util reserves the declared frame length
as soon as the 4-byte prefix arrives. With 64 global control-stream
permits a LAN host could make a responder reserve ~512 MiB by sending
nothing but length prefixes.
Changes:
- lanspread-proto gains MAX_REQUEST_FRAME_BYTES (64 KiB). Request
encode/decode enforce it in addition to the shared bound; Response
keeps the 8 MiB allowance.
- The server-side stream handler decodes inbound frames with a
request-sized codec. The response writer is unchanged.
- The server QUIC limits shrink the per-stream receive window to one
request frame and size the connection window so every one of the 32
allowed streams can hold its allowance (2 MiB per connection instead
of 8 MiB per stream).
Client-side decoders (network.rs, discovery Hello pulls) still use the
8 MiB bound because they read responses from identity-pinned peers.
Test plan: `just test` (proto tests assert the exact limits and that a
maximal request encodes far below the bound; stream tests assert the
inbound codec uses the request bound). Manual: three peer-cli
containers still exchange snapshots and complete downloads.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding EXP2-SEC-04. `validate_game_id` in the wire
protocol only enforced non-blank and a 255-byte maximum, so a request
such as `StreamInstall { game_id: "../../x" }` decoded successfully and
was passed on to the transfer layer. Every consumer resolves the ID
against the local catalog before touching the filesystem, so this was
not exploitable, but the protocol boundary is the right place to state
what a game ID is: the name of one catalog directory.
Requests and library snapshots now fail validation when the game ID
contains `/`, `\`, any Unicode control character (including NUL), or is
exactly `.` or `..`. A new `ControlValidationError::InvalidPathComponent`
variant reports the rejection. Embedded dots such as "game..v1" remain
valid because the catalog validators accept them.
Test plan: `just test` exercises the rejected forms plus an accepted ID
with embedded dots.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding NET-02. A discovered `_lanspread._udp` record
was accepted as a QUIC candidate as soon as it carried the current
protocol version and a peer_id TXT entry; the resolved socket address
itself was never inspected. Anyone on the LAN can publish mDNS records,
so a forged advertisement could point every peer's handshake attempt at
a multicast group (224.0.0.251), the broadcast address, 0.0.0.0/::, or
port 0. That wastes a discovery slot per record and sprays QUIC Initial
packets onto addresses no peer can ever answer from.
`validated_candidate_endpoint` now rejects unspecified, multicast and
IPv4 broadcast addresses as well as port 0 before the candidate enters
the negotiation set. Loopback and link-local addresses stay admissible
because single-host and DHCP-less LAN setups legitimately use them.
Test plan: `just test` (new unit tests cover the address filter and the
endpoint validator). Manually: start two peer-cli containers; discovery
still works with real interface addresses.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Security audit finding SEC-IPC-03. The thumbnail IPC command resolved
`assets/{game_id}.jpg` from the resource directory without checking the
ID, unlike every other command that maps a game ID to a path. A crafted
ID containing path separators could therefore read any `.jpg` reachable
from the resource root. The handler also still carried a `dbg!` that
printed the resolved path to stderr in release builds.
The command now rejects anything that is not a single normal path
component with an `InvalidInput` I/O error, using the same
`is_single_component_game_id` gate as run_game and start_server. The
frontend already treats a failed thumbnail request as "no thumbnail".
Test plan: `just clippy`, `just test`. In the app, thumbnails for
catalog games still load; an invoke with game_id "../x" is rejected.
Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
Game-directory selection could reach update_game_directory and then fail before
peer startup, while the frontend only logged the rejected invoke. Preserve the
last accepted root until peer acknowledgement, surface backend rejection in
the settings and main-window UI, and avoid holding the published control lock
across runtime replies. Startup preflight remains fail-closed; synchronous
setup stays lexically owned through scoped_blocking so cancellation cannot
strand a partially published runtime.
Add peer-cli scenario S50 to verify invalid changes preserve the existing
library and valid changes acknowledge and refresh the library in both
directions. Modernize the fixed-size hex decoders to satisfy the current
workspace Clippy lint without changing their behavior.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed
- `deno task build` -- passed
- `just peer-cli-tests S50` -- passed
- `just build` -- passed
- `git diff --cached --check` -- passed
Clarify that `just build` is the production no-bundle launcher build, while
fixture builds are explicit. Document package-directory generation, the
`LANSPREAD_GAMES_DIR` and `--set GAMES_DIR` default-run forms, the metadata
cache behavior, and the force-refresh override so the catalogue workflow is
usable without consulting the recipe implementation.
Test Plan:
- `prettier --check --prose-wrap always --print-width 80 README.md CLAUDE.md` -- passed
- `just --fmt --check` -- passed
- `git diff --cached --check` -- passed
The default GUI build previously selected the peer-CLI fixture catalog, which
made a successful no-bundle build unsuitable as a launcher built from real
packages. Route `build` through the production resource map and production
profile, retaining `build-fixture` for test-only GUI builds. Add explicit
production run/build recipes, generate the catalog before a package-dir build,
and let the default `run` route through that workflow when
`LANSPREAD_GAMES_DIR` or a Just variable override is supplied. Integrate the
metadata cache so unchanged `--all` generation is skipped, with
`LANSPREAD_CATALOG_FORCE=1` available for an explicit refresh.
The pre-existing release-mode change in the catalog recipe remains unstaged
and is intentionally not part of this commit.
Test Plan:
- `just --fmt --check` -- passed
- `just --dry-run build-production /srv/games` -- passed
- `just --dry-run catalog-generate-production /srv/games` -- passed
- `git diff --cached --check` -- passed
Complete production catalog generation hashes every package twice, which is
necessary for publication but wasteful when the same package tree has already
produced the current catalog. Add a metadata-only cache that records package
paths, sizes, nanosecond mtimes, catalog and unrar metadata, and output
identity. The cache is advisory: incomplete outputs never hit, and production
build validation remains the authority. Record files atomically under the
ignored local cache directory, with focused tests for hits, source changes,
and incomplete output.
Test Plan:
- `python3 -m unittest discover -s tools -p 'test_*.py'` -- passed
- `git diff --cached --check` -- passed
Catalog generation and peer install/download validation rejected any path component containing a tilde followed by digits, even when the component was a valid long filename such as Bosons TD Gold~1.w3m. Remove the heuristic from all three validators and retain the existing device-name and portable-alias checks. Add a regression test for the literal filename so catalog publication and later path validation agree.
Test Plan:
- just clippy (passed)
- just test (passed)
- git diff --check (passed)
Production catalog generation previously prepared every game serially and computed transfer-chunk hashes for extracted .eti files even though those hashes were discarded. Enable Rayon-backed BLAKE3 hashing, process selected games through the shared available-CPU pool, and hash large read blocks with update_rayon. Extracted outputs now use a whole-file-only hash path. Manifest collection remains deterministic and the independent second verification pass is preserved.
Test Plan:
- `just clippy` -- passed
- `just test` -- passed
Full catalog generation previously prepared each game sequentially, so a
version mismatch late in the catalog could surface only after earlier packages
had already been extracted and hashed. Validate every selected version.ini
against game.db before starting manifest preparation, while retaining the
per-package validation during preparation to detect later input changes.
Document the ordering and cover it with a regression where an earlier archive
would fail if archive processing began before a later version mismatch.
Test Plan:
- `just fmt` -- passed
- `just test` -- passed
- `just clippy` -- passed
- `git diff --cached --check` -- passed
Add small Justfile entry points for generating the complete production
catalog authority or safely regenerating one game. Keep fixture validation
separate and allow operators to select a trusted unrar executable.
Document the complete package layout, BLAKE3 and ContentId model,
full/incremental publication procedure, verification and bundle gates, and
peer-CLI workflows. Record the fresh final local acceptance run without
presenting Docker throughput as physical-LAN evidence.
Test Plan:
- just fixture-catalogs-check
- just test
- just clippy
- just frontend-test
- just build
- LANSPREAD_S37_MIN_MIB_PER_S=100 just peer-cli-tests
- just catalog-check-production (expected fail-closed: production manifests absent)
- deno fmt --check README.md organize/testing/PEER_CLI_SCENARIOS.md
- rumdl check --flavor commonmark README.md organize/testing/PEER_CLI_SCENARIOS.md
- just --fmt --check
- git diff --check
Keep the catalog authority safeguards while reducing task-runner duplication.
GUI run and build commands now validate only the default fixture profile they
actually package; the peer-CLI image and matrix retain the aggregate check for
default, solid, multi-archive, and unknown-game profiles.
Remove publisher-only format, lint, and test wrappers because the workspace
recipes already cover the same crate and targets.
Test Plan:
- `just --fmt --check` -- passed
- `just fixture-catalogs-check` -- passed
- `just test` -- passed
- `just clippy` -- passed
- `just frontend-test` -- passed, 91/91
- `just build` -- passed
- `git diff --cached --check` -- passed
Record the completed protocol-8 implementation, its security and lifecycle
decisions, and the final local acceptance evidence. Mark protocol-7 Call to
Play relay reviews as historical so they cannot be mistaken for current design.
Keep production acceptance honest by recording the unavailable canonical
186-game manifest corpus, real Windows/NTFS confinement and durability proof,
and representative physical-LAN evidence as external release prerequisites.
Test Plan:
- `just fmt` (passed)
- `just test` (passed; 708 workspace tests, including peer 480 and Tauri 56)
- `just clippy` (passed)
- `just frontend-test` (passed; 91/91)
- `just build` (passed; fixture-backed no-bundle build)
- `LANSPREAD_S37_MIN_MIB_PER_S=100 just peer-cli-tests` (passed; S1-S49)
- `git diff --cached --check` (passed)
Update user and developer documentation for the protocol-8 system: persistent
SPKI-derived identities, exact catalog ContentId authority, pinned responder
pulls, structured runtime ownership, direct-author Call to Play, and the global
local-network sharing switch.
Remove active descriptions of repository-wide certificates, pushed deltas,
relayed histories, and metadata consensus. Keep operational and UI boundaries
aligned with the implementation, including the fail-closed production catalog
gate.
Test Plan:
- `just fmt` (passed)
- `git diff --cached --check` (passed)
Replace legacy metadata and relay expectations with current protocol-8 JSONL
assertions. Scenarios now bind every source to authenticated PeerId and exact
ContentId, prove typed attempt lifecycle order, and fence cancellation,
quarantine, rollback, republishing, and peer-departure outcomes against vacuous
success.
Isolated topologies distinguish direct author pulls from relay and ambient mDNS
substitution. The run log records focused diagnostics and the final fresh-image
S1-S49 acceptance result without presenting Docker-host throughput as a
representative external-LAN measurement.
Test Plan:
- `LANSPREAD_S37_MIN_MIB_PER_S=100 just peer-cli-tests` -- passed S1-S49
- S37 -- passed 2,147,483,656 bytes in 17 chunks at 551.10 MiB/s
- `python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` -- passed
- `ruff check --select F,E9 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` -- passed
- `git diff --cached --check` -- passed
Add a durable, acknowledged Local network sharing switch with fail-closed
hydration, serialized mutation, and redacted ephemeral-identity diagnostics.
Keep local Call-to-Play state available while gating every network action on the
effective sharing generation.
Render revisioned verification, invalid-source retry, and sticky source
exhaustion states. Preserve opaque attempt IDs through progress delivery so
out-of-order webview events cannot attach stale bytes to a successor transfer,
and keep terminal exhaustion visible after the last source departs.
Own listeners, native invokes, persistence, dialogs, and companion-window
creation through webview close. Late creation is settled and cleaned before the
parent realm is destroyed.
Test Plan:
- `just frontend-test` -- passed (91/91)
- `just build` -- passed with TypeScript, Vite, and release Tauri compilation
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `git diff --cached --check` -- passed
Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.
Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.
The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.
BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.
Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
production manifest corpus is absent
- `git diff --cached --check` -- passed
Treat directory/file shape as part of each peer manifest vote and reject
portable aliases before aggregating descriptors. This keeps majority selection
deterministic and avoids collapsing conflicting entries that share a path or
size.
This preserves the previously staged consensus hardening before the protocol-8
catalog-authority cutover layered in the working tree.
Test Plan:
- `git diff --cached --check` -- passed
Treat cancellation as part of the Stream Install transport contract. Frame
production and QUIC egress now run as structured futures, cancellation wins at
queued sends, blocked writes, and close, and exceptional exits reset the send
stream before producer cleanup completes.
Make the unrar listing subprocess cancellation-aware and explicitly kill and
reap it on cancellation or pipe-capture failure. This ensures outbound transfer
tracking is cleared only after provider work is quiescent, which is required by
game-root mutation and directory-switch draining.
Test Plan:
- `just clippy` -- passed
- `just test` -- passed (242 lanspread-peer tests)
- `just peer-cli-build` -- passed
- `git diff --cached --check` -- passed
Require catalog game IDs and every remotely described path component to use
Unicode NFC before any download transaction begins. This prevents canonically
equivalent spellings from bypassing portable alias checks on filesystems that
normalize names, while preserving the protocol's exact path spelling.
Cover accepted NFC names and both game-ID and nested-component rejection with
zero-mutation tree snapshots.
Test Plan:
- `just clippy` -- passed
- `just test` -- passed (242 lanspread-peer tests)
- `git diff --cached --check` -- passed
Keep initial peer transfers, retry attempts, and chunk receivers structurally owned until they quiesce. Cancellation now stops opening new streams, flushes accepted file writes, and drains active work before ownership rollback can begin.
Leave operation admission owned by the running download task when liveness detects that every source disappeared, and emit the peers-gone notification only once.
Test Plan:
- just fmt (Rust and configured formatters completed; 39 pre-existing rumdl findings remain)
- just clippy
- just test
- git diff --cached --check
Validate portable aliases across committed and pending ownership sets when loading persisted records. Malformed state can no longer make recovery delete the pending file through an older case-only spelling.
Test Plan:
- just clippy
- just test
- just fmt (Rust, TOML, and Prettier completed; rumdl still reports 39 pre-existing issues)
Reject exact manifest destinations that are not covered by the last committed ownership set before creating a baseline or parking version.ini. Align Windows device-name validation with the confined filesystem backend and keep cleanup capability-relative.
Replace recursive downloaded-game removal with an empty ownership generation. The operation now removes only proven-owned files and the sentinel, preserves unknown files and directories, and remains recoverable and idempotent across crashes.
Test Plan:
- just clippy
- just test
- just fmt (Rust, TOML, and Prettier completed; rumdl still reports 39 pre-existing issues)
Remote manifests were validated before mutation, but preparation, chunk writes,
sentinel transactions, and ownership recovery later reopened ambient paths. A
link or reparse-point swap between those steps could redirect a mutation outside
the validated game root.
Introduce a retained ConfinedGameRoot capability backed by cap-primitives. Carry
typed validated destinations into chunk plans, walk every component without
following links, and perform payload, sentinel, stale-file, abort, and recovery
mutations relative to the retained handle. File writes and verification use the
same opened handle, while final durability syncs payload files and unique parent
directories before committing version.ini.
Make ownership-record publication phase-aware as well. A directory-sync failure
after record rename now stops before payload mutation without performing an
unsafe old-sentinel rollback. Record the capability-root, bounded-handle,
hard-link, and unproven Windows durability tradeoffs in the decision log.
Test Plan:
- `just clippy` -- passed
- `just test` -- passed; 185 peer tests and the full workspace are green
- `just fmt` -- Rust, TOML, and Prettier completed; command remains nonzero on
39 pre-existing rumdl issues outside this change
- `git diff --cached --check` -- passed
Track the exact regular files owned by each completed and in-flight peer
download instead of sweeping every non-reserved path after cancellation. Bind
the record to the canonical games directory, publish pending ownership before
payload mutation, and use the final version.ini rename as the recovery commit
point.
Make replacement, cancellation, and startup recovery preserve unknown files
and install state while removing stale or partial downloader-owned bytes. Add a
new-format baseline so legacy discarded sentinels cannot make partially
modified payloads ready, sync payload and journal state in transaction order,
and serialize startup recovery against operation admission.
Document ambiguous legacy target adoption, portable alias transitions, and the
other ownership tradeoffs in the refactor decision log.
Test Plan:
- `just clippy` -- passed
- `just test` -- passed (182 peer-core tests plus the full workspace)
- `just fmt` -- Rust, TOML, and Prettier formatting completed; the command then
stopped on 40 pre-existing rumdl findings in unrelated Markdown content
- `git diff --cached --check` -- passed
Game scanning, manifest validation, install recovery, migration, and download
cleanup each carried their own spellings and case rules for reserved entries.
Those copies had already diverged, which made it possible for one subsystem to
accept or expose a path that another treated as application-owned state.
Introduce one game_paths module for the canonical names and conservative
portable comparison policy. Keep context-specific predicates for manifest and
scanner protection versus cancellation preservation: cancellation still sweeps
its own version transaction scratch files, while install and migration state
survive. Reuse the constants for all production path construction sites.
Test Plan:
- `just test` -- passed (175 lanspread-peer tests and full workspace)
- `just clippy` -- passed
- `just fmt` -- Rust, TOML, and Prettier completed; the recipe remains blocked
by 39 pre-existing rumdl issues in five unrelated Markdown files
- `git diff --cached --check` -- passed
Why:
- Remote and UI-echoed file descriptions could reach transaction and storage
code one entry at a time, so a hostile late path could mutate earlier files.
- Per-file consensus also accepted malformed peer lists and let duplicate rows
inflate a source's vote.
What:
- Add a complete protocol-7 manifest adapter with catalog-root confinement,
portable path and alias rules, reserved-path protection, shape and size caps,
symlink/reparse inspection, and zero-mutation tests.
- Keep download selection in the peer core, validate every peer manifest before
consensus, and pass only the validated manifest into storage/orchestration.
- Canonicalize locally advertised paths, cap exact chunk receives, and preserve
the local-only install fast path.
- Record the chosen safety limits and follow-up ownership/catalog decisions.
Test Plan:
- just clippy
- just test
- just frontend-test
- just build
- just fmt (Rust/TOML/Prettier completed; rumdl reports 39 pre-existing issues)
- git diff --cached --check
Replace the earlier peer-authentication proposal with the reviewed,
implementation-oriented design. The plan now records the identity-storage
state machine, pinned TLS and endpoint rules, signed Call-to-Play objects,
download-source authorization, resource limits, safe protocol phases, and
phase-owned acceptance gates.
Remove the standalone review after incorporating its findings and follow-up
adjudication into the authoritative plan, including an explicit closure matrix.
This avoids maintaining two documents with conflicting severity and guidance.
Test Plan:
- `git diff --cached --check` -- passed
- Code tests not run; documentation-only change
Treat an unavailable actor ID or an explicit not-ready result as normal peer
startup and tell the user that LAN connection is still in progress. Clear that
message when snapshot registration succeeds.
Map obsolete, missing-history, and active-history-limit store failures to
distinct guidance without marking a healthy transport unavailable. Preserve a
generic message for unexpected publish failures.
Test Plan:
- just frontend-test
- just build
- git diff --cached --check
Pass the effective nomination deadline into the Add time action and extend
from whichever is later: that deadline or the current time. This preserves
remaining time when a call becomes ready early while still giving an overdue
call a fresh five-minute window.
Test Plan:
- just fmt
- just frontend-test
- just build
- git diff --cached --check
Keep complete running and cancelled histories visible for fifteen minutes so
peers retain the roster, chat, and outcome long enough to understand what
happened. Compact them to terminal tombstones afterward without charging
settled calls against the active-history limit.
Model running and cancelled as durable read-only frontend states, exclude
them from active badges, prune retired raw events, and document the lifecycle.
Add peer scenario S49 to prove a late joiner reconstructs a terminal call with
its roster and chat intact.
Test Plan:
- just fmt
- just clippy
- just test
- just frontend-test
- just build
- just peer-cli-tests S48 S49
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --cached --check
Raise the wire protocol to version 7 and add explicit Call to Play delivery
outcomes. Live requests now wait for an application acknowledgement, allowing
the sender to distinguish applied, duplicate, obsolete, incomplete, and
rejected updates instead of treating a successful write as acceptance.
Remove source-IP equality from actor verification. The receiver now requires
the envelope peer ID to be present in its known roster and requires every live
event actor to match that envelope. This matches the cooperative-LAN trust
model without misrepresenting the shared TLS identity as per-peer
authentication.
Transport failures, malformed responses, NeedHandshake, and NeedHistory each
trigger one asynchronous Hello/HelloAck resync. Rejections are logged without
retry, and local publication remains independent of remote availability.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `git diff --cached --check` -- passed
Replace per-event insertion with a transactional batch merge. The store now
validates and deduplicates an entire history before committing it, rejects
conflicting event IDs without partial mutation, evaluates retention after all
batch events are present, and reports applied, duplicate, obsolete, and
missing-root outcomes explicitly.
Derive event identity from retained history instead of preserving an unbounded
ID set. Expired histories can therefore be restored by a complete Create plus
AddTime batch, while orphan actions request history and terminal tombstones
continue to reject stale resurrection. Capacity applies only to unresolved
history, allowing Start and Cancel to settle a full call.
Only retained events reach the UI or live broadcast path. Handshake and live
merge callers log invalid or incomplete histories without publishing events
that compaction discarded.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `git diff --cached --check` -- passed
Expired call histories were removed on the next store insertion, so an otherwise
idle peer could continue carrying stale payload through handshakes after the
five-minute UI retention ended.
Run the same inactive-call compaction before local and handshake snapshots. This
makes the expiry boundary exact without trimming any event from an active call.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `git diff --cached --check` -- passed
A failed fire-and-forget event left one peer's active call state divergent until
some later discovery or reconnect happened. During a LAN party that could leave
different players looking at different rosters or chat.
Fall back to the existing bidirectional handshake whenever a live Call to Play
send fails. Its active-history exchange heals both sides immediately when the
failure was transient. Document the related wall-clock synchronization
assumption for deadline and countdown presentation.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
Deadline completion previously shared the green Ready presentation with a full
roster and remained visible forever. An abandoned call therefore looked ready
to launch and required its creator to return and cancel it.
Give elapsed calls a distinct Time's up state and a five-minute grace period in
which the creator can start or extend them. After that, both the reducer and
peer store remove the call as a unit. Filled calls remain ready until their
deadline, and active calls continue to retain complete history for late joiners.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just frontend-test` -- passed, 22 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
Calls for a game missing from the local catalog still contributed to the badge
but were discarded by both the ticker and overlay. Opening the badge could
therefore show a blank modal with no explanation.
Render those calls with an unavailable-game label, the sender and game ID, the
normal roster and chat, and safe coordination actions. The creator can mark the
match started, but automatic launch remains disabled until catalog data exists.
Test Plan:
- `just fmt` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `git diff --cached --check` -- passed
The 4,096-event store retained every completed call forever and local commands
only reported that they reached the queue. Once the bound was reached, GUI
actions could therefore fail with no user-visible result. The CLI snapshot wait
could also be satisfied by an unrelated live event.
Keep the complete event and chat history for every active call so late joiners
receive full context. When the creator starts or cancels a call, replace its
history with a single terminal tombstone; this bounds retained payload while
still healing peers that missed the live terminal action. Publish commands now
reply with the actual store result, and CLI snapshots use a direct reply.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, including full-cap terminal compaction
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
Participant maps and creator authorization previously used display names, so
two peers left at the default Commander name collapsed into one participant
and could exercise each other's creator controls through the normal client.
Carry a stable actor_id separately from actor_name. The peer overwrites actor_id
on every local publish, and live event envelopes are accepted only when the
known peer, source, and event actor match. The frontend keys participants and
authorization by actor_id while retaining actor_name for display. This follows
the trusted-LAN model and is not cryptographic authentication against a hostile
peer.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 21 tests
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
Implement the launcher design as a production peer-to-peer feature. Call to
Play actions are immutable, validated events broadcast over the existing QUIC
control channel, deduplicated in a bounded in-memory history, and exchanged in
Hello/HelloAck so late joiners reconstruct current calls.
Add the Tauri bridge and modular launcher surfaces for play-now and scheduled
calls, check-in, readiness buffers, role-aware controls, chat, tickers, and
actual caller launch. A deterministic frontend reducer derives presentation
state from replicated history. Extend the JSONL peer harness with publish/list
commands and a three-peer live-delivery and late-join scenario.
This intentionally raises the only supported wire protocol from version 5 to
version 6; older builds are not supported. Document the transport architecture
and exclude generated peer-test state from Docker build contexts.
Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed, 20 tests
- `just build` -- passed
- `just peer-cli-tests S2 S48` -- passed
- `git diff --cached --check` -- passed
Extend the launcher design specification and prototype reference with the
"Call to Play" multiplayer coordination feature and clean up top-bar chrome.
Key additions and changes:
- Spec & Roadmap: Document Call to Play mechanics in SPEC.md, including Play
Now / Scheduled call flavors, 15-minute check-in windows, top-bar button
with active count badge, quick-bar ticker stack above grid, overlay modal,
and per-call group chat. Update design/README.md.
- Components & Logic: Add calltoplay.jsx and ctp-chat.jsx components for call
creation, status progression, and chat. Extend data.jsx with a mock peer
roster (PEERS) and helper functions for peer install count tracking.
- Top-bar Cleanup: Move game-folder configuration from top bar into Settings ->
Library, freeing top-bar space for the Call to Play action button.
- Styling & Layout: Add CTP quick-bar, ticker, badge, card, and chat styles to
styles.css, and integrate CTP components into launcher.jsx, components.jsx,
and SoftLAN Launcher.html.
Test Plan:
- `git diff --cached --check` -- passed (no trailing whitespace or conflict markers)
- `cargo check --workspace` -- passed cleanly
- Manual review of staged diff across 9 design/launcher files -- confirmed clean staging
Bug report: unrar.exe kept running after closing the launcher during a
game install. The orphaned process kept extracting in the background and
held file handles on the staging directory.
Root cause (regular install path): run_unrar_sidecar ran the unrar
sidecar via tauri-plugin-shell's Command::output(). That helper spawns
the process on a detached SharedChild OS thread and immediately drops the
CommandChild; there is no Drop impl that kills the process. On app exit
only shutdown_peer_runtime ran, and Windows does not cascade-kill child
processes, so closing the launcher left unrar running.
Fix:
- run_unrar_sidecar now uses .spawn() instead of .output(), keeping a
killable CommandChild. It registers the child in a new
LanSpreadState.active_unrar_children registry and an RAII
UnrarChildGuard deregisters it on every return path. The CommandEvent
stream is drained to reproduce the exact stdout/stderr (NEWLINE_BYTE
is b'\n'), status code, success flag, and UnpackLogEntry the old
.output() produced, so logging behavior is unchanged.
- kill_active_unrar_children() runs in the RunEvent::Exit handler before
shutdown_peer_runtime, killing every in-progress unrar. Killing first
also lets the install task unwind so the runtime stops promptly.
Two concurrency hazards were closed in the registry design:
- Children are keyed by a monotonic id, not pid. A pid key let a
finishing install's guard deregister a different install's child after
the OS recycled the pid, which could re-orphan a live child.
- A shutting_down latch lives in the registry under the same mutex as
the kill sweep. The sweep is a one-shot drain, so a child registered
after it (a task caught between spawn() and registration, or a later
archive in a multi-archive install -- unpack_archives does not observe
the shutdown token) would be missed. Registration now checks the latch
under that mutex and kills the child immediately instead of inserting
it. Since registration and the sweep serialize on one mutex, every
interleaving kills the child.
Also hardened the streamed-install sender path (ExternalUnrarStream
Provider) with kill_on_drop(true) on its tokio unrar spawns, so a
dropped or aborted producer task cannot orphan unrar there either.
Known limitation: a hard force-kill or crash of the launcher (e.g. Task
Manager -> End Task) bypasses RunEvent::Exit and is not covered. Making
that bulletproof would require a Windows Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; the reported "close the launcher"
case is fully fixed.
Test Plan:
- just clippy (pedantic, -D warnings): clean.
- just fmt: no changes.
- just test: all suites pass (incl. the 20 Tauri-lib unit tests).
- Manual (Windows): start a game install, close the launcher mid-extract,
confirm no unrar.exe remains in Task Manager. Repeat with two
concurrent installs and with a multi-archive game.
An adversarial audit of the headless peer-to-peer scenario suite
(crates/lanspread-peer-cli/scripts/run_extended_scenarios.py, driven by
`just peer-cli-tests`) found assertions that passed even when the behavior
they claim to test was not happening, plus timing races and doc-vs-code
divergences. A full baseline run (S1-S47) passed beforehand, confirming these
were test-quality gaps, not peer regressions; the baseline output itself
exposed the worst offenders -- e.g. S14 chunk totals {128 MiB, 1 MiB} (a
two-chunk file whose "balanced within one chunk" check can never fail) and
S16/S18 serving the whole ~120 MiB alienswarm.eti from a single source, so
fanout and retry were never exercised.
Test-correctness fixes (a broken behavior could previously pass green):
- S18: the "no download-failed" check was dead -- it reused a LineWaiter
already advanced past download-finished, so it scanned an empty tail.
Replaced with assert_no_event_since over the whole window. Switched to a
4*CHUNK_SIZE sparse archive so both peers get chunks; the test now proves the
download SURVIVES a mid-download source kill (every byte delivered, survivor
served part, no download-failed, clean diff). Retry-onto-survivor is the
mechanism but is not asserted: the kill/serve race against `docker rm -f`
cannot be forced, so asserting an exact split would be flaky.
- S7: the only check was a diff against byte-identical ggoo fixtures, so it was
source-agnostic. Added assertions that the download committed exactly once,
every chunk came from the validated two-peer set, both peers served, and no
chunk was fetched twice.
- S14: enlarged to 4*CHUNK_SIZE so the balance check can fail under a 3+1
imbalance; asserts an exact 2+2 split summing to the file size.
- S16: inflated the .eti to 2*CHUNK_SIZE so it fans out across both
catalog-version peers (the stock 120 MiB fixture is a single chunk).
- S37: validate the throughput rate fields (positive, self-consistent
mbit/mib == 8.388608, mib_per_s == bytes/duration), not just the byte count.
- S35: assert the source actually advertises the unknown game before checking
it is filtered, so "absent" means "filtered" and not "never sent".
- S15: cross-check each peer's raw advertised eti_version via list-peers; the
list-games eti_game_version is synthesized from the catalog and can only ever
equal the asserted value.
- S2: poll for library convergence and verify the bidirectional exchange
(bravo sees alpha's 3 games, not just alpha seeing bravo's 4).
- S12/S28: require the gating unit test to appear as "<name> ... ok" so an
#[ignore]d (un-run) test no longer satisfies the check.
- S24/S25: assert the requested install=false final state.
- S34: assert exactly 21 coherent chunks (20 files + version.ini), 21 distinct
paths, no duplicates, instead of a >= 21 floor.
Flake fixes:
- S19: force-kill the sole source right after download-begin on a 4*CHUNK_SIZE
file and accept download-failed or download-peers-gone. The old graceful
shutdown on a single-chunk file could let the transfer finish first, turning
the expected failure into a download-finished. A chunk may complete before
the kill lands, but the full transfer cannot, so the failure is deterministic.
- S26: use a large sparse source so the first operation is reliably still
active when the duplicate request is issued (TOCTOU on active_operations);
also assert the active operation == "Downloading".
- S11: drop the "listener address must change" assertion -- it tested the OS
ephemeral-port allocator and could fail spuriously; keep the same-identity /
no-duplicate invariant.
Coverage and determinism:
- S27: add handshake::tests::inbound_hello_from_self_is_ignored for the
protocol-level self guard. The CLI scenario only exercises the CLI
string-compare guard, which short-circuits before any network call, so the
peer-crate guard had no test.
- find_fixture_game now iterates sorted(FIXTURES) so the ambiguous cnctw
(fixture-bravo/multi/solid) resolves deterministically to fixture-bravo.
Reviewed and deliberately left as-is (documented in the run log): S20, S21,
S30, S32/S39/S44 absence checks, S42 IP-order precondition, S45.
PEER_CLI_SCENARIOS.md rows S2, S11, S14, S16, S18, S19, S27 are updated to
match the harness, and a dated run-log entry records the audit, the fixes, the
accepted items, and the live-run evidence.
Test Plan:
- `just peer-cli-tests` (rebuilds the image, runs S1-S47 in Docker): baseline
passed; post-fix passed; a final run on the exact committed code passed
47/47. Evidence: S14 {268435456, 268435456} balanced 2+2; S16 .eti split
across B and C {134217728, 134217728}; S18 all 536870912 bytes delivered with
no download-failed; S19 deterministic download-failed; S37 ~874 MiB/s.
- `just test` (incl. inbound_hello_from_self_is_ignored), `just clippy`
(-D warnings, all-targets), and `just fmt` all pass.
Refs: PEER_CLI_SCENARIOS.md scenario matrix and 2026-06-21 run-log entry.
Add a peer-cli-tests just recipe as the public entry point for the existing
extended scenario runner. The recipe depends on peer-cli-image, so Docker
uses its normal layer cache to decide whether the image needs rebuilding
before the Python harness runs.
This gives contributors one memorable command for the full S1-S47 matrix
while still allowing selected scenarios as positional arguments. The
underlying runner continues to own container topology and JSONL command/event
handling.
Test Plan:
- just --list
- just --dry-run peer-cli-tests S39 S40
- git diff --check
Refs: PEER_CLI_SCENARIOS.md
An emit-vs-listen audit of the event surface showed the GUI is
state-as-source-of-truth: useGames renders the complete `games-list`
snapshot (full library + active_operations) and reconstructs status from
it, not from a stream of granular events. Several PeerEvents were emitted
but had no consumer at all -- no frontend `listen()` and no peer-cli
scenario assertion -- so they were pure dead weight that made the backend
look event-driven when it no longer is.
This prunes that dead surface in two parts.
1. Remove three PeerEvent variants with no consumer: InstallGameBegin,
UninstallGameBegin, and RemoveDownloadedGameBegin. The operation-start
transition is still observable via ActiveOperationsChanged (the
snapshot already carries the Installing/Updating/Uninstalling/
RemovingDownload kind), so nothing is lost. This drops their emit
sites in handlers.rs, the begin-event assertions in the peer's
lifecycle unit tests (the asserted sequence is now
ActiveOperationsChanged(kind) -> LocalLibraryChanged ->
ActiveOperationsChanged([]) -> *Finished), the peer-cli JSONL
mappings (install-begin/uninstall-begin/remove-download-begin) plus
the now-orphaned install_operation_name helper and InstallOperation
import, and the matching Tauri handler arms.
2. Drop Tauri webview emits that no frontend listener consumed:
peer-local-ready, game-download-begin, game-download-pre,
game-download-finished, game-uninstall-finished, and
peer-connected/-disconnected/-discovered/-lost. The log lines and all
real side effects are kept (handle_got_game_files still forwards
PeerCommand::DownloadGameFiles). The orphaned emit_peer_addr_event and
handle_download_finished helpers and the now-unused SocketAddr import
are removed. peer-runtime-failed is kept pending a decision on
surfacing runtime failures in the GUI.
Why not re-wire instead: under state-as-source-of-truth, per-event UI
state is exactly the pattern this project abandoned. Live progress
already flows via game-download-progress, and the peer-cli's chunk,
timing-trigger, and transition assertions read events that are retained
(download-begin, download-chunk-finished, the *-finished/*-failed
terminals), so test coverage is unchanged.
Behavior change: none functional. The Tauri backend no longer emits
events nothing listened to; the GUI is unchanged. The peer-cli no longer
emits the three *-begin JSONL events. PeerEvent is a workspace-internal
UI-reporting type, not a wire-protocol type, so there is no protocol or
version impact and all consumers are updated in this commit.
Docs: PEER_CLI_SCENARIOS.md S39 no longer lists install-begin (with a
note that the start transition is visible via active-operations-changed),
and a dated Run Log entry records the removal. The historical 2026-05-18
run-log note is left intact as a dated observation.
Test Plan:
- just test: pass (incl. peer lifecycle event-sequence tests).
- just clippy: pass (-D warnings, all targets).
- just frontend-test: pass (11/11, incl. streamed-install gating/labels).
- just build: pass (release, no bundle).
- Not run: the Docker S39-S47 matrix (run_extended_scenarios.py); those
scenarios never asserted the removed *-begin events, so coverage is
unaffected. just fmt's tombi step needs network and was skipped; no
TOML changed.
Refs: peer event-surface emit-vs-listen audit; no external consumers of
the removed events.
`just clippy` was failing on peer test and helper code after the current lint
set denied warnings. This keeps the cleanup local to the reported warnings and
avoids changing runtime peer behavior or the wire protocol surface.
The test helpers now avoid needless ownership, active-operation assertions take
slices, and the local monitor watch-event helper no longer wraps a value that
cannot fail. The install recovery matrix keeps the same cases, but moves the
case table and per-case assertions into helpers so the test body stays below the
clippy line limit. The remaining lint fixes replace similar temporary names and
use an explicit `expect` message for test-only length conversion.
No separate project documentation update is needed because this only changes
internal test/helper structure and lint-only assertions; the existing peer
architecture docs remain accurate.
Test Plan:
- just fmt
- just clippy
- just test
- git diff --check
- git diff --cached --check
Refs: none
A streamed install sender kept the original frame sink alive outside the
producer task. After the producer sent Complete, or an Error for a provider
failure, the forwarding loop still had a live mpsc sender in scope and waited
forever for another frame.
Move the sink into the producer so the channel closes when the producer exits.
That lets the QUIC writer close, the request task return, and the outbound
TransferGuard drop after successful streamed installs and provider-side
failures.
The peer-cli harness now keeps the outbound-transfer map it passes into the
peer runtime and exposes per-game counts in status. S39 asserts that the source
has no active outbound transfer for cnctw after the streamed install finishes,
which catches the sender-side lifecycle leak that receiver-only assertions
missed. The peer-cli README and scenario table document that status field and
expectation.
Test Plan:
- just fmt
- just test
- just clippy
- git diff --check
- git diff --cached --check
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S39 S40 --build-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 S42 S43 S44 S45 S46 S47
Refs: NEXT_STEPS.md streamed install lifecycle hardening
Claude Fable 5's branch review found that receiver cancellation or a QUIC
send failure could leave the sender-side archive producer blocked on the
bounded frame channel. That kept the outbound transfer guard alive and could
block later installs or updates of the same game.
Route archive frames through a cancellable StreamInstallFrameSink instead of
exposing the raw channel sender to providers. The QUIC forwarder now cancels
and closes the receive side before awaiting the producer, so a blocked send
wakes and the transfer guard can drop normally.
Make PeerCommand::StreamInstallGame own its peer metadata preflight inside the
peer core. The Tauri layer now sends the command directly, and the peer runtime
fetches file details from catalog-version peers before running the existing
majority validation and retry logic. This removes the UI-only pending streamed
install set and gives PeerEvent::GotGameFiles one meaning again: continue a
normal archive download.
Tighten the receiver transaction edge cases too. Rollback removes a newly
created empty game root, but preserves pre-existing roots. Once streamed
staging has been promoted to local/, intent or launch-settings cleanup failures
are logged for startup recovery instead of reporting a failed install for bytes
that are already committed.
Accept missing RAR CRC32 metadata for zero-byte files as CRC32 00000000 while
still requiring CRC32 metadata for non-empty files. Update the peer README,
scenario docs, and next-steps handoff so the documented ownership and remaining
trust limitation match the implementation.
Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- git diff --check
- python3 -m py_compile \
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \
S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image
Refs: streamed-install review handoff from Claude Fable 5