Limit server-wide decoded control requests to 16 and gate Stream Install providers behind a separate two-slot semaphore. The provider permit remains held through producer cleanup, bounding concurrent unrar work while ordinary bulk transfers retain their own capacity.
Test Plan:
- just test (passed after rerunning one transient ETXTBSY failure)
- just fmt (Rust formatting passed; repository's generated security report still triggers pre-existing rumdl violations)
- git diff --check
A valid remote view could make the renderer reduce and mount thousands of calls
every second. A single long chat also re-sorted its growing message array after
every append during every reduction.
Keep only the highest-ranked 128 nominations and latest 256 messages for UI
projection. Events are sorted once before folding, so chat messages remain in
stable order without repeated growing-array sorts. Backend author history and
creator authority are unchanged.
Test Plan:
- `just frontend-test` -- passed; 94 frontend tests passed.
- `just fmt` -- TypeScript formatting completed; the recipe then hit the
pre-existing generated security-report Markdown-lint failures.
- `git diff --cached --check` -- passed.
Wire-level author limits still allowed one peer to publish thousands of creator
roots and a Sybil set to retain hundreds of thousands of aggregate events.
Those valid snapshots were repeatedly projected for the desktop.
Limit one author to 128 creator roots and all retained remote history to 16,384
events. Over-budget revisions replace that author's slice with an empty
watermark, which frees memory and prevents liveness from pulling the rejected
snapshot every five seconds. Local author capacity remains independent.
Test Plan:
- `just test` -- passed outside the sandbox; 498 peer tests and all workspace
targets passed.
- `just fmt` -- Rust formatting completed; the recipe then hit the pre-existing
generated security-report Markdown-lint failures.
- `git diff --cached --check` -- passed.
Per-peer library limits still allowed 64 authenticated identities to retain a
large aggregate map and repeatedly clone it for UI publication.
Cap retained remote availability at 16,384 rows. A peer whose new projection
would exceed the budget stays authenticated with an empty library slice, and
its accepted revision is retained so liveness does not repeatedly pull the same
over-budget snapshot.
Test Plan:
- `just test` -- passed outside the sandbox; 496 peer tests and all workspace
targets passed.
- `just fmt` -- Rust formatting completed; the recipe then hit the pre-existing
generated security-report Markdown-lint failures.
- `git diff --cached --check` -- passed.
A failed catalog chunk could try every authenticated peer identity. Since each
source owns a fresh transfer deadline, one host could use Sybil identities to
turn a single unavailable chunk into a multi-hour retry sequence.
Count the initial source and cap one chunk at eight distinct peer identities.
Integrity quarantine, transport classification, cancellation, and explicit
user retries retain their existing behavior.
Test Plan:
- `just test` -- passed outside the sandbox; 495 peer tests and all workspace
targets passed.
- `just fmt` -- Rust formatting completed; the recipe then hit the pre-existing
generated `security-report/report.md` Markdown-lint failures.
- `git diff --cached --check` -- passed.
The selected game root could supply an unbounded legacy library index whose
revision was copied verbatim into app-owned state. A revision of `u64::MAX`
made every later checked revision advance fail even after the original root was
removed.
Read only a bounded regular, non-link file whose identity stays stable across
the read. Deserialize it before publication, preserve its cached game data, and
reset its stale revision authority to zero. Existing app-owned state still wins
without reading the legacy source, and invalid input remains in place for
recovery.
Test Plan:
- `just test` -- passed outside the sandbox; 494 peer tests and all workspace
targets passed.
- The initial sandboxed `just test` reached an unrelated Unix-socket permission
denial, then passed unchanged with the required socket permission.
- `git diff --cached --check` -- passed.
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