docs(peer): document authenticated sharing architecture
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)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# lanspread-peer proposed protocol and architecture
|
||||
# lanspread-peer 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.
|
||||
The peer uses mDNS discovery, authenticated QUIC transport, responder-owned
|
||||
metadata snapshots, and catalog-authorized file transfers. Wire protocol 8 and
|
||||
ALPN `lanspread/8` are the only supported wire mode; there is no legacy decode,
|
||||
fallback, or compatibility shim.
|
||||
|
||||
## Goals (unchanged)
|
||||
|
||||
@@ -11,136 +12,181 @@ file transfers.
|
||||
- UI drives operations through `PeerCommand`, peers remain headless.
|
||||
- Peers can appear/disappear at any time without data loss.
|
||||
|
||||
## Installation identity and QUIC authentication
|
||||
|
||||
- One installation-local Ed25519 key signs one self-issued TLS certificate.
|
||||
`PeerId` is BLAKE3 over the certificate's complete canonical DER
|
||||
SubjectPublicKeyInfo, encoded as 52 lowercase unpadded RFC 4648 base32
|
||||
characters. It is not a UUID, display name, IP address, or wire assertion.
|
||||
- The desktop app stores a strict version-1 record at `peer-identity-v1.json` in
|
||||
its Tauri app-data directory. The record binds the Ed25519 certificate,
|
||||
private key, SPKI-derived `PeerId`, and exact SAN. Reads and publication do
|
||||
not follow symlinks or reparse points, creation is no-clobber, corrupt bytes
|
||||
are quarantined before replacement, and private material is kept at mode 0600
|
||||
where the platform supports Unix modes.
|
||||
- Persistence failure produces a typed `Ephemeral` outcome and a fresh identity
|
||||
for that runtime; it never falls back to another peer-ID scheme. The peer CLI
|
||||
can instead select an existing record with `--identity-file`; that path is
|
||||
loaded strictly and is never repaired, replaced, or generated implicitly.
|
||||
- Identity state assumes one normal application owner for its app-data directory
|
||||
and deliberately has no cross-process identity lease. Retained no-follow
|
||||
handles contain path traversal and external-target mutation, and publication
|
||||
does not overwrite an existing winner. A concurrent same-user writer can still
|
||||
cause availability or continuity loss during quarantine; it is outside the
|
||||
supported ownership model.
|
||||
- QUIC uses the rustls provider with TLS 1.3 only. Every outbound operation
|
||||
carries a `PeerEndpoint { peer_id, addr }`; the expected ID is encoded in an
|
||||
exact SNI name, checked against the responder certificate SPKI, and the real
|
||||
TLS 1.3 CertificateVerify signature is delegated to rustls. ALPN is
|
||||
`lanspread/8`. Client resumption and 0-RTT are disabled; the server issues no
|
||||
tickets and sends no early or half-RTT data.
|
||||
|
||||
## 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)
|
||||
- `peer_id` (the canonical SPKI-derived ID)
|
||||
- `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`.
|
||||
1. Parse `peer_id`, address, and `proto_ver` into a candidate `PeerEndpoint`.
|
||||
Discovery is not authentication and does not add the peer to `PeerGameDB` or
|
||||
emit UI membership events. The mDNS ingress queue and active candidate
|
||||
negotiations are each capped at 64.
|
||||
2. Reserve a candidate negotiation lease before queueing or awaiting work, then
|
||||
establish a TLS-pinned connection to that exact endpoint. Missing or non-v8
|
||||
records are rejected, and neither an ephemeral QUIC source port nor a payload
|
||||
can replace the candidate listener address.
|
||||
3. Send the empty `Hello` pull request. The pinned responder returns a
|
||||
`HelloSnapshot` containing a `PeerStateSnapshot` with `runtime_session_id`,
|
||||
`library`, and `call_to_play`: only that responder's local library and local
|
||||
Call-to-Play author slice.
|
||||
4. Decode the strict bounded wire shape, then validate the library and
|
||||
Call-to-Play domains independently. Commit the endpoint and each valid domain
|
||||
only if the candidate lease is still current for both peer ID and address.
|
||||
This domain isolation lets an invalid remote Call-to-Play slice be cleared or
|
||||
preserved according to its session without discarding a valid library.
|
||||
5. Assign a fresh endpoint generation to every successful authentication.
|
||||
Dropped, failed, or superseded work releases its candidate claims, and a late
|
||||
result cannot mutate a newer generation.
|
||||
|
||||
### 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.
|
||||
- Call to Play actions are broadcast as immutable, uniquely identified events.
|
||||
- Successful liveness probes update `last_seen` only when both the typed
|
||||
endpoint and its authenticated generation are still current. Stale probes
|
||||
cannot refresh or remove a reauthenticated endpoint.
|
||||
- `Ping` receives a `Pong` carrying `PeerRevisions` with the runtime-session ID
|
||||
and both domain revisions. A session or revision mismatch schedules a full
|
||||
pinned pull, so idle liveness heals dropped change hints.
|
||||
- Local library or Call-to-Play changes send cheap revision hints. Hints are
|
||||
untrusted and lossy: unknown claims are ignored, and a known claim can only
|
||||
coalesce a pull from the claimed peer's already authenticated endpoint.
|
||||
- The state-sync scheduler caps its ingress and tracked peers at 64, coalesces
|
||||
work for five seconds, and runs at most eight pinned pulls and eight hint
|
||||
sends concurrently. Child work is drained lexically on shutdown.
|
||||
- The server admits at most 64 unauthenticated handshakes, 64 established
|
||||
connection scopes, and 32 control streams per connection. Each control stream
|
||||
accepts one frame followed by request EOF and sends at most one response; the
|
||||
frame cap is 8 MiB and control I/O has ten-second deadlines.
|
||||
|
||||
### Call to Play replication
|
||||
|
||||
Call to Play is transient peer-session state rather than database state. The
|
||||
peer keeps a bounded event history, deduplicated by event ID. Every event and
|
||||
chat message remains in the snapshot for the full lifetime of an active call, so
|
||||
a peer joining mid-call receives the complete context. A creator's `Start` or
|
||||
`Cancel` makes the call terminal and read-only, but its complete roster and chat
|
||||
history remain in snapshots for 15 minutes so late joiners can see the outcome.
|
||||
After that display window the history compacts to the Start or Cancel tombstone
|
||||
for the rest of the peer session. Active and recently terminal calls are never
|
||||
partially trimmed. If genuinely active history reaches the bound, local
|
||||
publishes return an error to the caller instead of appearing to succeed;
|
||||
terminal histories and tombstones do not consume that active-history capacity. A
|
||||
call whose deadline elapses remains available for five minutes so the creator
|
||||
can start or extend it, then its unresolved history is evicted as a unit. A
|
||||
local action is applied to that history, sent to the UI, and broadcast to every
|
||||
currently known peer. An incoming live event is applied once and sent to the UI
|
||||
without being rebroadcast, which prevents forwarding loops.
|
||||
Call to Play is transient, responder-owned state. Each peer serves exactly one
|
||||
local author snapshot:
|
||||
|
||||
Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate,
|
||||
and obsolete events need no follow-up. An unknown envelope peer, missing call
|
||||
root, transport failure, or malformed acknowledgement makes the sender perform
|
||||
one normal `Hello` / `HelloAck` exchange with that peer. The handshake carries
|
||||
the full retained history in both directions, so a transient request failure
|
||||
heals without waiting for mDNS rediscovery or a later reconnect. A rejected
|
||||
event is logged without retry. Local publication remains successful while this
|
||||
healing happens asynchronously, so an offline peer cannot block an action.
|
||||
```text
|
||||
CallToPlayAuthorSnapshot { revision, display_name, events }
|
||||
CallToPlayAuthorEvent { id, call_id, at, action }
|
||||
```
|
||||
|
||||
Actors are keyed by the peer's stable ID and carry a separate display name. The
|
||||
origin peer overwrites the actor ID on local actions. A live-event envelope must
|
||||
name a peer already in the receiver's roster, and every enclosed actor ID must
|
||||
match that envelope. This prevents accidental identity mixing and protects
|
||||
creator controls from other normal clients. It is not authentication against a
|
||||
hostile LAN peer: all peers use the shared application TLS identity, and stable
|
||||
peer IDs are self-asserted under the project's trusted-LAN model.
|
||||
The outer `PeerStateSnapshot` owns the runtime-session ID. A `CallId` encodes
|
||||
the creator's typed `PeerId` plus a 128-bit nonce. The UI supplies only a local
|
||||
intent and current display name; the peer core generates call/event nonces and
|
||||
timestamps. Identity and display name are not repeated inside events. Creator
|
||||
actions (`Create`, `Start`, `Cancel`, and `AddTime`) must come from the
|
||||
`CallId.creator`; participant authors may contribute only participant actions.
|
||||
|
||||
`Hello` and `HelloAck` include each side's event history. This lets peers that
|
||||
join after a call was created reconstruct the same nominations, responses,
|
||||
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
|
||||
deterministically and derives deadlines and check-in phases from timestamps.
|
||||
Those phases compare creator-supplied wall-clock timestamps with each viewer's
|
||||
local wall clock, so LAN machines are assumed to be synchronized closely enough
|
||||
for human-scale minute countdowns; clock skew shifts the displayed boundary by
|
||||
the same amount. There is deliberately no compatibility path for older protocol
|
||||
versions.
|
||||
The receiver assigns author identity from the pinned `PeerEndpoint`, validates
|
||||
and normalizes the whole author slice off-lock, then commits it against that
|
||||
endpoint's current generation and runtime session. For the same session, only a
|
||||
higher revision replaces the slice; an equal identical snapshot is a no-op, an
|
||||
equal different snapshot is a conflict, and a lower revision is stale. A new
|
||||
authenticated session clears the previous slice before accepting valid new
|
||||
state. Invalid same-session state preserves the last valid slice, while invalid
|
||||
new-session state leaves that author absent.
|
||||
|
||||
Participant events are retained but hidden until the creator's direct author
|
||||
slice supplies the call root. Removing a participant removes only that author's
|
||||
rows; removing the creator hides the entire call. Because every snapshot is
|
||||
served only by its author and attributed from the pinned responder, there is no
|
||||
union merge, relay, live-event push, acknowledgement, or author claim on the
|
||||
wire. The UI receives a deterministic complete replacement view.
|
||||
|
||||
Unresolved call history is removed as a whole after the deadline plus five
|
||||
minutes; terminal history is removed as a whole after `Start` or `Cancel` plus
|
||||
15 minutes. No tombstone survives pruning. Pruning a local slice advances its
|
||||
revision before a snapshot or `Pong` is served, allowing peers to discover the
|
||||
removal. The store caps total authors at 64, events per author at 4,096, and one
|
||||
encoded author snapshot at 4 MiB, with reserved capacity for terminal actions.
|
||||
|
||||
### 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.
|
||||
- Cancellation stops admission, drains all lexically owned connection, stream,
|
||||
state-sync, operation, and mDNS children, closes the shared endpoints, and
|
||||
joins the supervisor.
|
||||
- There is no `Goodbye` control message. Pinned liveness failure and stale
|
||||
generation-conditional removal are authoritative for departure.
|
||||
|
||||
## Library sync protocol
|
||||
|
||||
### Summary and snapshot
|
||||
The responder's full library slice is
|
||||
`LibrarySnapshot { revision, games: Vec<GameAvailability> }`, where each sorted,
|
||||
unique availability row contains only `game_id` and the catalog-derived exact
|
||||
`ContentId`. A snapshot contains at most 4,096 games. It does not carry titles,
|
||||
versions, paths, sizes, file lists, hashes chosen by the peer, installed state,
|
||||
or another peer's availability.
|
||||
|
||||
- `LibrarySummary { peer_id, summary: { library_rev, library_digest, game_count } }`
|
||||
- `LibrarySnapshot { peer_id, snapshot: { library_rev, games: Vec<GameSummary> } }`
|
||||
The empty `Hello` request always returns the responder's current complete slice.
|
||||
For the same runtime session, a higher revision atomically replaces the cached
|
||||
slice; lower or equal stale work cannot roll it back. A new authenticated
|
||||
runtime session resets the old domain before its candidate snapshot is
|
||||
considered. `LibraryChanged(ChangeHint)` carries only an untrusted claimed
|
||||
identity, runtime session, and revision. It never mutates a library directly and
|
||||
has no delta payload. A pinned `Pong` revision mismatch schedules the same full
|
||||
pull.
|
||||
|
||||
### Delta updates
|
||||
## Local library publication
|
||||
|
||||
- `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.
|
||||
- Only real local availability changes advance the revision and schedule a hint;
|
||||
there is no periodic list broadcast.
|
||||
- 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.
|
||||
new path without changing peer availability.
|
||||
- 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.
|
||||
- The remote UI projection is a wholesale replacement derived from all current
|
||||
authenticated per-peer slices; two peers offering the same game remain
|
||||
distinct exact-content sources.
|
||||
|
||||
## 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.
|
||||
1. Maintain a persistent revisioned on-disk index of local summaries and cheap
|
||||
per-game fingerprints (root-level `version.ini` contents/mtime, root-level
|
||||
`.eti` name/size/mtime, `local/` presence, and recovery state).
|
||||
2. Poll a bounded non-recursive metadata snapshot once per second and update
|
||||
only changed games.
|
||||
3. Keep a 300-second fallback scan to recover from missed events.
|
||||
|
||||
### Fast-path scanning
|
||||
@@ -150,7 +196,7 @@ versions.
|
||||
- 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.
|
||||
- If the fingerprint is unchanged, reuse the cached local summary.
|
||||
- Only run a recursive scan for new or changed games.
|
||||
|
||||
## Local State and Recovery
|
||||
@@ -179,11 +225,14 @@ Reserved per-game paths:
|
||||
flight.
|
||||
- `games/<game_id>/install_intent.json` in the configured state directory is the
|
||||
atomic per-game intent log.
|
||||
- `games/<game_id>/download_ownership.json` in that state directory records the
|
||||
last committed and any pending downloader-owned regular-file set. The record
|
||||
is bound to one canonical configured games directory.
|
||||
- `games/<game_id>/download_ownership/v1-<root_digest>/record.json` in that
|
||||
state directory records the last committed and any pending downloader-owned
|
||||
regular-file set and its exact catalog content ID. The namespace is derived
|
||||
from one canonical configured games directory, and the record retains the full
|
||||
lossless root identity. Same-ID state for different configured roots therefore
|
||||
remains independent.
|
||||
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
||||
when the current intent is `None`.
|
||||
when no active intent independently proves transaction ownership.
|
||||
|
||||
Downloaded-file removal is not an uninstall transaction. For a catalog ID that
|
||||
is a single direct child of the configured game directory, it requires a valid
|
||||
@@ -192,12 +241,18 @@ ownership record and regular root-level `version.ini`, and refuses `local/`,
|
||||
generation, deletes only committed downloader-owned files and the sentinel, and
|
||||
keeps the game root plus every unknown file or directory.
|
||||
|
||||
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.
|
||||
The state layout is game-first: root namespaces live under each game ID rather
|
||||
than under a global root tree. A former singleton ownership record is migrated
|
||||
once into its derived namespace with copy-first durable publication. Singleton
|
||||
state is not accepted as a parallel runtime format, and conflicting or split
|
||||
migration evidence fails closed.
|
||||
|
||||
Recovery reads app-state `install_intent.json` and combines the recorded active
|
||||
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. Settled state is represented by absence of the intent;
|
||||
markerless `.local.*` directories are then left untouched.
|
||||
|
||||
Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`,
|
||||
`.softlan_game_installed`, and `local/.softlan_first_start_done` files are
|
||||
@@ -210,21 +265,49 @@ 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.
|
||||
- The complete remote description is converted into a
|
||||
`ValidatedDownloadManifest` before any destination mutation. It contains
|
||||
canonical game-root-relative paths and rejects aliases, reserved state, shape
|
||||
conflicts, and bounded-size violations as one unit.
|
||||
- Tauri and the peer CLI inject one immutable `CatalogBundle`: `game.db` defines
|
||||
catalog identity/version, the mandatory compact
|
||||
`manifests/catalog-content-index-v1.jsonl` maps every exact catalog row to its
|
||||
expected `ContentId` and Stream Install capability, and
|
||||
`manifests/<game_id>.json` defines canonical paths, kinds, sizes, 128 MiB
|
||||
chunks, and BLAKE3 values. The index has exact database coverage and is loaded
|
||||
once; manifest bodies remain on demand and must recompute both indexed fields.
|
||||
Disk body loads and full validation recheck the durable publication marker
|
||||
before and after their work. Runtime peers cannot add catalog entries or
|
||||
replace that byte authority.
|
||||
- Authenticated remote-availability joins use the compact index only. Unknown or
|
||||
mismatched `(game_id, content_id)` pairs are rejected without filesystem I/O
|
||||
or manifest parsing; actual serving, download planning, and Stream Install
|
||||
still load and validate the full body.
|
||||
- The local catalog artifact is converted into a `ValidatedDownloadManifest`
|
||||
before any destination mutation. It contains canonical game-root-relative
|
||||
paths and rejects aliases, reserved state, shape conflicts, and bounded-size
|
||||
violations as one unit. Remote peers never define the storage plan.
|
||||
- Source selection requires the exact catalog `(game_id, content_id)` pair from
|
||||
an authenticated peer's current library slice. Every ordinary chunk request
|
||||
repeats the exact `ContentId`, `CanonicalCatalogPath`, offset, and length.
|
||||
There is no version-only or address-only eligibility adapter.
|
||||
- A new manifest target that already exists without prior committed ownership is
|
||||
rejected before the sentinel, ownership journal, or payload is mutated.
|
||||
- Download mutation holds a capability handle for the direct catalog game root.
|
||||
Directory components and final files are reopened relative to that handle
|
||||
without following links or Windows reparse points; chunk writes and checks use
|
||||
the same opened file handle.
|
||||
- Downloads remain chunked QUIC streams with the existing integrity checks.
|
||||
- Downloads hash every chunk, including the buffered `version.ini`, as it is
|
||||
received and require the catalog BLAKE3 value, exact length, and exact end of
|
||||
stream. One absolute ten-minute application deadline covers the complete
|
||||
open/request/receive/check lifecycle of each ordinary chunk.
|
||||
- An integrity failure quarantines `(PeerId, content_id)` in shared memory for
|
||||
the rest of that peer runtime, independent of address changes. Transport
|
||||
failures remain retryable without quarantine. Each failed chunk may try every
|
||||
distinct eligible peer identity once; there is no separate numeric retry cap.
|
||||
- A no-transfer local shortcut is allowed only when settled ownership records
|
||||
the exact expected committed content ID. A matching `version.ini` alone, or a
|
||||
legacy/pre-content-ID record, is not catalog-content proof.
|
||||
- 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.
|
||||
- The sender admits only an exact catalog path and, for ranged requests, an
|
||||
exact catalog chunk range before opening the retained no-follow file handle.
|
||||
- `local/` paths are never served, even if a stale or malicious manifest request
|
||||
asks for them.
|
||||
- Cancelling or recovering a download removes only paths named by its durable
|
||||
@@ -236,65 +319,69 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
||||
|
||||
- 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.
|
||||
- A sender admits Stream Install only for a manifest with verified extracted
|
||||
output and only when its direct regular root `.eti` set exactly equals the
|
||||
catalog archive set. Missing or extra archives stop before the extraction
|
||||
provider receives authority.
|
||||
- The Stream Install request names the exact catalog `ContentId`. All
|
||||
path-bearing frames, including archive names, use `CanonicalCatalogPath` and
|
||||
the fallible checked decoder distinguishes malformed framing from an explicit
|
||||
sender `Error` frame.
|
||||
- The receiver independently requires the exact catalog archive set and the
|
||||
complete extracted path/kind/size set. It hashes every regular file with
|
||||
BLAKE3 while writing and verifies every expected entry before commit. Regular
|
||||
files may occur only once across the entire archive set; repeated directory
|
||||
entries are allowed because they carry no bytes. RAR CRC32 remains only an
|
||||
early corruption signal, not an authority boundary.
|
||||
- Integrity failures quarantine the source for that catalog content and retry a
|
||||
fresh transaction from another eligible peer. Transport failures retry without
|
||||
quarantine; local I/O and cancellation stop. Every failed attempt rolls back
|
||||
before the next source begins.
|
||||
- After complete catalog verification, account/language/persona settings are
|
||||
rewritten in staging before promotion. The one-shot launch-settings marker is
|
||||
written only after successful promotion; if it cannot be written, first play
|
||||
safely retries the rewrite.
|
||||
- A game without a verified extracted-file manifest neither offers nor accepts
|
||||
Stream Install. There is no CRC32-only fallback.
|
||||
|
||||
## Catalog publication and packaging
|
||||
|
||||
- `lanspread-catalog-publisher` generates production manifests and their
|
||||
complete compact identity index beside the canonical packages, independently
|
||||
rebuilds/verifies each selected artifact, and uses a durable corpus marker so
|
||||
an interrupted body/index publication fails closed. Incremental generation
|
||||
requires an existing exact indexed corpus and atomically republishes the full
|
||||
index after the selected bodies. After it owns the marker, it freshly
|
||||
validates every unselected body/index pair before deriving that mixed index;
|
||||
no pre-marker snapshot can roll another completed publication backward.
|
||||
- `lanspread-fixture-catalog` is a separate test-only generator. It derives
|
||||
reduced peer-CLI `game.db` files and manifests from explicitly selected
|
||||
fixtures; those outputs are development and acceptance-test authority only.
|
||||
- Tauri fixture builds require both the exact committed development resource map
|
||||
and `LANSPREAD_USE_FIXTURE_CATALOG=1`. Every other build mode defaults to the
|
||||
production resource map, and the custom production profile cannot be
|
||||
downgraded to fixtures.
|
||||
- Production packaging requires the complete production `game.db`/manifest
|
||||
corpus to pass `check --all`. The canonical production packages and their 186
|
||||
generated manifests are not present in this checkout, so that gate remains
|
||||
intentionally blocked; fixture success is not production completion.
|
||||
|
||||
## 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.
|
||||
- Every authenticated peer is keyed by `PeerId`, not by IP address.
|
||||
- An authenticated `PeerEndpoint` retains the candidate or explicitly supplied
|
||||
listener address. Payload fields and ephemeral QUIC source ports cannot
|
||||
rewrite it, and no network caller can dial an address without the expected
|
||||
responder ID.
|
||||
- Every successful authentication assigns a fresh endpoint generation. Refresh,
|
||||
liveness update, and stale removal are conditional on the exact endpoint and
|
||||
generation they observed.
|
||||
- Runtime-session IDs and per-domain revisions prevent delayed snapshots from
|
||||
rolling state back across restart or endpoint replacement. Change hints never
|
||||
bypass the pinned pull and generation checks.
|
||||
- A generation-conditional authenticated departure removes that peer's library
|
||||
and Call-to-Play slices and publishes complete replacement views. Ordinary
|
||||
short-lived QUIC connection closure is not roster departure.
|
||||
- Protocol 8 is strict and current-only. Unknown fields, noncanonical typed IDs
|
||||
or paths, duplicate/unsorted library rows, oversized domains, extra control
|
||||
frames, and v7 messages are rejected instead of adapted.
|
||||
|
||||
Reference in New Issue
Block a user