Files
lanspread/crates/lanspread-peer/ARCHITECTURE.md
T
ddidderr bb7497c0ff refactor(peer): name streamed integrity boundary
NEXT_STEPS item 4 needed the streamed-install integrity model to be a
conscious decision. Keep the current runtime behavior, but name it as
sender archive integrity: the receiver verifies streamed file size and
RAR CRC32 from the sender's archive metadata before committing the
install transaction.

This protects against truncation, transport corruption, and stream
provider bugs. It deliberately does not claim malicious-peer protection,
because the sender controls both the streamed bytes and the RAR metadata.
The docs now say that trusted content requires a future catalog schema
with catalog-owned archive or extracted-file SHA-256 hashes.

Test Plan:
- just fmt
- just test
- just clippy
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S41 --build-image
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 4
2026-06-07 22:05:03 +02:00

235 lines
10 KiB
Markdown

# lanspread-peer proposed protocol and architecture
This document proposes a tighter, more fault-tolerant protocol while keeping
the current idea: mDNS discovery, QUIC transport, on-demand metadata, and
chunked file transfers.
## Goals (unchanged)
- Local LAN discovery via mDNS.
- QUIC + JSON messages for control, raw streams for file data.
- UI drives operations through `PeerCommand`, peers remain headless.
- Peers can appear/disappear at any time without data loss.
## Peer lifecycle and message flow
### 1) Startup and advertise
- Start QUIC server.
- Advertise via mDNS with TXT records:
- `peer_id` (stable ID, not tied to IP)
- `proto_ver`
- `library_rev` (monotonic local library revision)
- optional `hostname`
### 2) Discovery and handshake
When a peer is discovered:
1. Connect and send `Hello { peer_id, proto_ver, listen_addr, library_rev,
library_digest, features }`. `listen_addr` is mandatory; the QUIC source port
is only a temporary transport port and must not be recorded as the peer's
listener.
2. Receive `HelloAck { peer_id, proto_ver, listen_addr, library_rev,
library_digest, features }`.
3. If the remote `peer_id` is already known but the address changed, update it.
4. If protocol versions are incompatible, drop the peer (and keep mDNS watching).
5. If library digests match, do nothing else.
6. If digests differ:
- If we have a known `library_rev` for that peer, request `LibraryDelta`.
- Otherwise request `LibrarySnapshot`.
### 3) Steady state
- Any message updates `last_seen`.
- Pings run only when idle (or on a longer interval), not every 5 seconds.
- Library updates are pushed as deltas, debounced and coalesced.
### 4) Shutdown
- Optional `Goodbye { peer_id }` lets others remove the peer quickly.
- If a peer vanishes without goodbye, stale timeout + ping removal handle it.
- Goodbye is a hint, never required for correctness.
## Library sync protocol
### Summary and snapshot
- `LibrarySummary { peer_id, summary: { library_rev, library_digest, game_count } }`
- `LibrarySnapshot { peer_id, snapshot: { library_rev, games: Vec<GameSummary> } }`
### Delta updates
- `LibraryDelta { peer_id, delta: { from_rev, to_rev, added, updated, removed } }`
- `removed` is a list of game IDs.
- Deltas are idempotent; ignore if `to_rev` <= known rev.
### GameSummary (concept)
- `id`, `name`, `eti_version`, `size`, `downloaded`, `installed`
- `manifest_hash` (hash of file list + sizes)
- `availability` (e.g., `ready`, `downloading`, `local_only`)
## When peers broadcast their game list
- Only on changes, not on a timer.
- Filesystem events are gated per game ID instead of time-debounced:
- an active operation lock drops events for that game;
- a rescan already running for the ID sets a rescan-pending flag;
- the running rescan loops once more when that flag was set.
- Local library scans emit `LocalLibraryChanged` only for real library changes,
except that accepted game-directory changes can force a UI snapshot for the
new path without sending a peer delta.
- Active operation mutations emit `ActiveOperationsChanged` from the mutation
path instead of riding on local library scans.
- Send `LibraryDelta` to known peers; send `LibrarySummary` on new connections.
## Local game scanning: fast and low cost
### Strategy
1. Maintain a persistent on-disk index (per game):
- `manifest_hash`, total size, file list (optional), and a fingerprint
(root-level `version.ini` mtime, root-level `.eti` mtime/size, and
`local/` directory presence).
2. Use filesystem watchers to update only changed games.
3. Keep a 300-second fallback scan to recover from missed events.
### Fast-path scanning
- On startup, list only top-level game directories.
- For each game, read a cheap fingerprint:
- root-level `.eti` file names, sizes, and mtimes
- root-level `version.ini` mtime
- presence of `local/` as a directory
- If fingerprint unchanged, reuse cached size and manifest hash.
- Only run a recursive scan for new or changed games.
## Local State and Recovery
Downloaded and installed are independent predicates:
- `downloaded` is true only when `<game_root>/version.ini` exists as a regular
file. The sentinel is written last through `.version.ini.tmp` and atomic
rename. An interrupted replacement leaves no restored old sentinel because
archive bytes may already have changed.
- `installed` is true when `<game_root>/local/` is a directory. The contents of
`local/` are user-owned and are skipped by manifests, fingerprints, and file
serving.
- Install and update transactions unpack into staging, then overwrite the first
discovered game-provided `account_name.txt` and `language.txt` files under
the staged tree from launcher settings before promoting it to `local/`.
Reserved per-game paths:
- `.version.ini.tmp` and `.version.ini.discarded` are download transaction
scratch files and are swept during startup recovery.
- `.local.installing/` is extraction staging.
- `.local.backup/` holds the previous install while an update or uninstall is in
flight.
- `games/<game_id>/install_intent.json` in the configured state directory is the
atomic per-game intent log.
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
when the current intent is `None`.
Downloaded-file removal is not an uninstall transaction. It removes the whole
game root only for a catalog ID that is a single direct child of the configured
game directory, has a regular root-level `version.ini`, and has no `local/`,
`.local.installing/`, or `.local.backup/` path.
Recovery reads app-state `install_intent.json` and combines the recorded intent
with the observed `local/`, `.local.installing/`, and `.local.backup/` state.
Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of
the corresponding reserved directories even if the marker was not flushed before
a crash. With intent `None`, markerless `.local.*` directories are left
untouched.
Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`,
`.softlan_game_installed`, and `local/.softlan_first_start_done` files are
handled only by the dedicated pre-start migration phase. Normal operation does
not read legacy state paths.
### Result
Most scans become O(number of game dirs), with full recursion only when needed.
## File manifests and downloads
- Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated
calls can be skipped when unchanged.
- Downloads remain chunked QUIC streams with the existing integrity checks.
- A game is transferable only when its ID is in the catalog, no operation is
active for that ID, and the root-level `version.ini` sentinel exists.
- `local/` paths are never served, even if a stale or malicious manifest request
asks for them.
- Cancelling a download discards the peer-owned root download payload and
scratch sentinel files. `local/` and install transaction metadata are
preserved, so a cancelled update of an installed game settles as local-only.
### Streamed install integrity
- Low-disk streamed installs request archive-derived file bytes from one peer
and write them directly into the install transaction staging directory.
- The receiver verifies every streamed file against the sender archive's file
size and RAR CRC32 before the transaction may commit. This catches truncated
streams, transport corruption, and provider bugs.
- This is not malicious-peer protection: the peer controls both the archive
metadata and the streamed bytes. A trusted-content model needs catalog-owned
hashes, either for the root archives or for extracted files, and receiver-side
SHA-256 verification against those catalog values before commit.
## Fault tolerance rules
- Every peer is keyed by `peer_id`, not by IP address.
- Peer addresses are listener addresses from mDNS or `Hello`/`HelloAck`, never
ephemeral QUIC source ports.
- `library_rev` is monotonic and guards against out-of-order updates.
- Any mismatch or missing delta falls back to `LibrarySnapshot`.
- Loss of goodbye is harmless; stale timeout is authoritative.
## Roadmap from current design to this one
1. Protocol updates in `lanspread-proto`:
- Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`,
`LibraryDelta`, and optional `Goodbye` messages.
- Thread `peer_id`, `library_rev`, and `manifest_hash` through all
library and manifest-bearing types.
- Make `Hello` and `HelloAck` carry the sender's `listen_addr`,
`library_rev`, and `library_digest` so both sides can record stable
listener addresses and immediately select `LibraryDelta` vs
`LibrarySnapshot`.
2. Peer identity:
- Persist a stable `peer_id` (UUID) in the peer config and inject it into
`PeerInfo` and `PeerGameDB` at startup.
- Track `peer_id -> SocketAddr` in the discovery table and update the
address on any incoming handshake or mDNS refresh.
3. Discovery handshake:
- Publish `peer_id` and `library_rev` in mDNS TXT records to avoid
immediate TCP/QUIC roundtrips when nothing changed.
- Add a lightweight handshake in `run_peer_discovery` that exchanges
`Hello`/`HelloAck` before any library sync.
- Ignore peers that do not advertise the current protocol version.
4. Library revisioning:
- Store a monotonic `library_rev` locally and increment only after a
successful index refresh completes.
- Apply `LibraryDelta` when `library_rev` matches; reject stale or future
revisions and request `LibrarySnapshot` instead.
- Cache the last accepted `manifest_hash` per peer to short-circuit
manifest requests when unchanged.
5. Local index + scan optimizations:
- Use the cached `local_library/index.json` file in the configured state
directory to store per-root fingerprints and computed manifests.
- Use filesystem watchers with a debounce window to collect changes and
incrementally update the cache.
- Schedule a low-frequency full scan to reconcile missed watcher events.
6. Announce updates:
- Broadcast `LibraryDelta` updates keyed by `library_rev`.
- Send `LibrarySummary` on new connections to seed the delta flow.
7. File manifest caching:
- Store per-game `manifest_hash` and only fetch details when changed.
8. Liveness:
- Reduce ping frequency; update `last_seen` on any message.
- Add optional `Goodbye` on shutdown paths.
9. Tests:
- Delta apply/merge, rev ordering, manifest hashing, and scan cache behavior.