Commit Graph
166 Commits
Author SHA1 Message Date
ddidderr 3450d27742 fix(peer): bound aggregate launch-settings reads
Charge every SmartSteamEmu.ini chunk to one 64 MiB aggregate budget and the existing ten-minute scan clock. All candidate reads still finish before the first write, so exhaustion cannot leave partial settings or a completion marker.

Test Plan:
- just test
- just clippy
- aggregate-read zero-partial-write regression
- git diff --check
2026-09-12 13:35:36 +02:00
ddidderr eda006c66d fix(peer): isolate response buffer budgets by IP
Acquire one 8 MiB endpoint-IP budget before the shared 32 MiB control-response pool. Same-origin maximal-prefix waiters now stay out of the fair global semaphore queue, so they cannot head-of-line block small responses from other LAN hosts.

Test Plan:
- just test
- just clippy
- cross-origin semaphore ordering regression
- git diff --check
2026-09-12 13:34:42 +02:00
ddidderr 0c26aba9e9 fix(peer): bound Stream Install receive retries
Share one catalog-derived deadline across at most four distinct endpoint-IP attempts: ten minutes plus two catalog copies at 1 MiB/s. Keep the ten-minute inactivity timer, but renew it only after a nonempty catalog-checked file chunk is written to staging.

Cancellation, rollback, integrity quarantine, and public exhaustion states retain their existing semantics.

Test Plan:
- just test
- just clippy
- shared-deadline, source-IP budget, metadata-inactivity, and useful-byte regressions
- git diff --check
2026-09-12 13:23:55 +02:00
ddidderr a687e0c2b8 fix(peer): preserve control capacity across origins
Set the per-origin control-stream share to eight against the sixteen-slot global pool, preventing one observed host from occupying every decoded control task.

Test Plan:
- just test
- just clippy
- exact admission-limit regression
- git diff --check
2026-09-12 13:22:51 +02:00
ddidderr a5c3a21142 fix(peer): cap aggregate chunk retry time
Start one twenty-minute retry deadline only after an initial chunk failure, retain it across authenticated source and IP changes, and give each in-flight retry the earlier of that deadline and its ten-minute attempt limit. Expiry remains a non-quarantining transport failure.

Test Plan:
- just test
- just clippy
- three paused-time retry-deadline regressions
- git diff --check
2026-09-12 13:22:33 +02:00
ddidderr 42cf98ecec fix(peer): charge discovery quota to packet origin
Carry the mDNS response source independently of its advertised A or AAAA target. Active and cooling candidates now charge the observed host, so rotating peer IDs, ports, and target addresses cannot escape the eight-candidate origin budget.

Test Plan:
- just test
- just clippy
- rotating-advertised-target regression
- git diff --check
2026-09-12 13:09:06 +02:00
ddidderr 49f8eef7b5 style(peer): format security hardening
Record the deterministic nightly rustfmt output for the newly added event-channel and retry code after the repository-wide formatter gate was repaired.

Test Plan:
- just fmt
- just clippy
- git diff --check
2026-09-12 13:07:14 +02:00
ddidderr f6af9b9420 fix(peer): bound local monitor snapshots
Filter roots through the catalog before retaining them, cap entries, games, and poll time, and preserve the previous complete snapshot on failure. Repeated failures back off exponentially and a root change resets retry state.

Test Plan:
- just test
- just clippy
- focused catalog-filter, budget-preservation, and backoff tests
- git diff --check
2026-09-12 13:06:32 +02:00
ddidderr 37420e26ed fix(peer): coalesce full UI view publications
Keep one queued and one replaceable pending snapshot for remote-library and Call-to-Play views while preserving lifecycle-event FIFO delivery. Generation barriers and fenced drains prevent stale nonempty views from crossing disable or acknowledgement boundaries.

Test Plan:
- just test
- just clippy
- focused burst, lifecycle ordering, fence, repeated-barrier, and stale-view tests
- independent ordering review
- git diff --check
2026-09-12 13:06:10 +02:00
ddidderr 470e119515 fix(peer): preflight launch settings mutations
Bound recursive setting discovery and INI reads, and finish every scan/read before the first write. Budget exhaustion can no longer leave partial setting changes or a misleading completion marker.

Test Plan:
- just test
- just clippy
- focused traversal, oversized INI, and zero-partial-write tests
- git diff --check
2026-09-12 13:00:31 +02:00
ddidderr 43f78a3d01 fix(peer): preflight legacy migration work
Bound legacy traversal by entry count and cooperative elapsed time, and complete the read-only plan before mutating any source or destination. Reuse the catalog-sized legacy-index read ceiling.

Test Plan:
- just test
- just clippy
- focused preflight and oversized migration tests
- git diff --check
2026-09-12 13:00:23 +02:00
ddidderr cd50f78854 fix(peer): bound local library ingestion
Cap index reads, games, archive fingerprints, selected-root entries, recursive depth and bytes, scan time, and version.ini reads. Failed rescans retain the prior complete index and revision instead of publishing partial state.

Test Plan:
- just test
- just clippy
- focused oversize index, traversal, and prior-snapshot tests
- git diff --check
2026-09-12 13:00:04 +02:00
ddidderr 01d92b1922 refactor(peer): split bulk request dispatch
Move file-chunk and Stream Install branches into focused helpers so the new admission path remains within the workspace's strict Clippy limits without changing request behavior.

Test Plan:
- just clippy
- git diff --check
2026-09-12 12:59:05 +02:00
ddidderr b3174deabf fix(peer): deduplicate chunk retries by source IP
Track both authenticated peer IDs and endpoint IPs for each failed chunk. Rotating keys or ports at one host no longer grants another automatic ten-minute transfer attempt, while the existing eight-attempt ceiling remains.

Test Plan:
- just test
- focused same-IP Sybil and distinct-source selection tests
- git diff --check
2026-09-12 12:53:54 +02:00
ddidderr e96f322aad fix(peer): enforce per-origin server admission
Bound each observed remote IP to eight connections, sixteen control streams, eight bulk transfers, and one Stream Install provider. Drop-owned leases release on normal completion or unwind, and per-origin saturation cannot consume another resource class.

Test Plan:
- just test
- focused port rotation, distinct-origin, drop/unwind, class isolation, and provider saturation tests
- git diff --check
2026-09-12 12:52:55 +02:00
ddidderr dfc858d0e2 fix(peer): bound aggregate control response buffering
Read the four-byte response length explicitly, acquire bytes from one 32 MiB connector-wide budget before buffering, and require exact body plus EOF. One-way requests now check EOF without allocating an 8 MiB decoder.

Test Plan:
- just test
- focused complete, oversized, truncated, trailing, one-way, and shared-budget tests
- git diff --check
2026-09-12 12:41:39 +02:00
ddidderr 145610c0a1 fix(peer): bound authenticated identities per endpoint IP
Allow at most eight committed peer identities at one pinned QUIC endpoint IP after Hello authentication. Reject excess keys before address-owner eviction, while removals release capacity and other LAN hosts remain admissible.

Test Plan:
- just test
- focused port-rotation, ninth-key, address-owner, other-IP, and release tests
- git diff --check
2026-09-12 12:41:20 +02:00
ddidderr 06cd8b93ed fix(peer): enforce absolute transfer send deadlines
Use one ten-minute authority window for all raw chunk writes and FIN, and one window for Stream Install producer waits, frames, and FIN. Successful incremental writes can no longer renew a public bulk slot indefinitely.

Test Plan:
- just test
- focused paused-time cumulative-write regressions
- git diff --check
2026-09-12 12:36:48 +02:00
ddidderr ff962801ae fix(peer): cap public control and extractor concurrency
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
2026-09-12 12:32:12 +02:00
ddidderr 30663fea34 fix(peer): bound remote Call-to-Play state
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.
2026-09-12 12:26:55 +02:00
ddidderr 0189622085 fix(peer): bound aggregate remote library state
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.
2026-09-12 12:23:42 +02:00
ddidderr c892838d72 fix(peer): bound per-chunk retry source fanout
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.
2026-09-12 12:21:36 +02:00
ddidderr 7de373afb6 fix(peer): sanitize legacy library index migration
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.
2026-09-12 12:19:50 +02:00
ddidderr 0a38dfbb19 fix(peer): reject unsafe game IDs in state marker paths
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
2026-09-12 11:14:44 +02:00
ddidderr 63aa4bc77c fix(peer): serve manifests from the validated cache only
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
2026-09-12 11:12:14 +02:00
ddidderr 55fa4941bc fix(peer): gate bulk requests on the compact index before loading manifests
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
2026-09-02 22:37:40 +02:00
ddidderr 5c9f8bb321 fix(peer): fail instead of falling back to /tmp for the state directory
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
2026-09-02 22:34:40 +02:00
ddidderr ec35826173 fix(peer): apply catalog portability rules in validate_relative_path
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
2026-09-02 22:34:40 +02:00
ddidderr 84cbabfeba fix(install): skip and reject links when extracting .eti archives
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
2026-09-02 22:32:43 +02:00
ddidderr 887e245638 fix(peer): honour change hints only from the claimed peer's address
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
2026-09-02 22:30:28 +02:00
ddidderr 139defa7ae fix(peer): cap discovery candidates per source IP to resist mDNS floods
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
2026-09-02 22:30:28 +02:00
ddidderr 3965e2544c fix(peer): bound inbound request frames at 64 KiB instead of 8 MiB
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
2026-09-02 22:27:39 +02:00
ddidderr e938767d66 fix(peer): drop non-unicast or zero-port mDNS discovery candidates
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
2026-09-02 22:27:39 +02:00
ddidderr a49b51d3d8 fix(tauri): make game-directory changes observable
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
2026-08-25 00:10:01 +02:00
ddidderr 76eec55103 fix(paths): accept literal tilde-digit filenames
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)
2026-08-20 08:38:36 +02:00
ddidderr 60fd7ba0c2 feat(peer)!: cut over to authenticated catalog sharing
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
2026-08-10 13:59:18 +02:00
ddidderr 36c4785775 fix(peer): preserve manifest entry shape consensus
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
2026-08-10 13:58:37 +02:00
ddidderr 290af433c7 fix(peer): make streamed egress cancellation-safe
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
2026-08-09 22:01:34 +02:00
ddidderr bcdede7fad fix(peer): reject non-normalized download paths
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
2026-08-09 22:01:00 +02:00
ddidderr 5bb4a8b611 fix(peer): drain cancelled download workers
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
2026-08-09 20:05:42 +02:00
ddidderr 7a77d3ffd1 fix(peer): reject aliased ownership generations
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)
2026-08-09 19:48:21 +02:00
ddidderr 08b1cb5c1d fix(peer): preserve untracked download files
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)
2026-08-09 19:46:10 +02:00
ddidderr 691176e1d5 fix(peer): confine download mutations to game root handles
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
2026-08-09 19:22:11 +02:00
ddidderr 62cd9306bd feat(peer): journal download file ownership
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
2026-08-09 18:44:40 +02:00
ddidderr a1013b028d refactor(peer): centralize game root path policy
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
2026-08-09 17:59:01 +02:00
ddidderr a6ed60a538 feat(peer): validate manifests before download mutation
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
2026-08-09 17:51:11 +02:00
ddidderr 9c34efa705 feat(call-to-play): retain terminal outcomes
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
2026-07-23 18:03:57 +02:00
ddidderr e5d70ae56f fix(call-to-play): acknowledge live replication
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
2026-07-23 17:49:01 +02:00
ddidderr be7ad2e560 refactor(call-to-play): merge histories atomically
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
2026-07-23 17:44:03 +02:00
ddidderr d58307c328 fix(call-to-play): compact expired snapshots
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
2026-07-21 22:59:48 +02:00