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)
386 lines
20 KiB
Markdown
386 lines
20 KiB
Markdown
# Peer authentication refactor decisions
|
|
|
|
This file records implementation choices that are not fully determined by
|
|
`PEER_AUTH_PLAN.md`, especially choices where every available option has a
|
|
meaningful downside.
|
|
|
|
## 2026-08-09 — Bound protocol-7 download descriptions generously
|
|
|
|
**TL;DR:** Until catalog manifests replace remote descriptions, accept at most
|
|
100,000 entries, 1 TiB per ordinary file, 64 KiB for the in-memory `version.ini`
|
|
sentinel, 255 bytes per path component, 900 bytes per relative path, and 16 TiB
|
|
in aggregate for one game. Resolved destinations are capped at 1,000 platform
|
|
path units.
|
|
|
|
The plan requires count, per-file, and aggregate limits but intentionally does
|
|
not prescribe values. These limits are far above the expected game and CLI
|
|
fixture sizes while bounding allocation, planning work, and arithmetic.
|
|
|
|
Alternatives:
|
|
|
|
- Lower product-sized limits would reject abusive inputs earlier, but risk
|
|
inventing constraints that real large games or mod packs exceed.
|
|
- Limits near `usize`/`u64` maxima avoid practical compatibility concerns, but
|
|
do not meaningfully bound memory or work.
|
|
- Configurable limits add settings and test combinations without a current user
|
|
need; protocol safety limits should be consistent between installations.
|
|
|
|
## 2026-08-09 — Track download ownership; never infer it from unknown files
|
|
|
|
**TL;DR:** Persist an atomic per-game set of successfully downloaded paths under
|
|
application state. On replacement, remove only paths in the previous owned set
|
|
that are absent from the new authoritative manifest. If old or corrupt state has
|
|
no trustworthy set, preserve unknown root files.
|
|
|
|
The existing game directory has no provenance information. Deleting every
|
|
non-reserved path would remove stale downloads, but could also delete a user's
|
|
own files and contradict the plan's preservation rule. The first update after
|
|
upgrading may therefore leave legacy stale files; subsequent successful
|
|
downloads have exact ownership and can clean safely.
|
|
|
|
Alternatives:
|
|
|
|
- Bootstrap ownership from the current directory. This is seamless, but can
|
|
permanently misclassify user files as Lanspread-owned and later delete them.
|
|
- Delete every non-reserved file missing from the new manifest. This is simple
|
|
and cleans legacy state completely, but is unsafe for user files.
|
|
- Delete only familiar archive suffixes such as `.eti`. This avoids most user
|
|
files, but bakes package naming guesses into the security boundary and cannot
|
|
clean general catalog payloads.
|
|
- Never clean stale files. This is safest for user data, but fails the explicit
|
|
successful-replacement requirement and can keep serving obsolete payload.
|
|
|
|
## 2026-08-09 — Generate production manifests outside the source checkout
|
|
|
|
**TL;DR:** Provide a small deterministic generator binary that runs next to the
|
|
canonical production packages and writes the JSON manifest artifacts that are
|
|
then stored and packaged with Lanspread.
|
|
|
|
This checkout has catalog metadata for 186 games but canonical package bytes
|
|
only for a small CLI fixture set. Hashes cannot be derived honestly without the
|
|
bytes. Keeping the generator here makes the format reproducible without
|
|
requiring production game packages in Git.
|
|
|
|
Alternatives:
|
|
|
|
- Store production packages in this repository. That would make generation
|
|
self-contained, but is impractical for size and distribution reasons.
|
|
- Generate hashes at application runtime. This would make local peer bytes the
|
|
authority, defeating catalog-owned verification and adding startup work.
|
|
- Accept hashes announced by peers. This is easy to deploy, but provides no
|
|
protection against a peer that supplies both the bytes and their claimed hash.
|
|
|
|
## 2026-08-09 — Bind ownership records to one canonical games directory
|
|
|
|
**TL;DR:** A per-game ownership record includes an opaque, exact identity for
|
|
the canonical configured games directory. A record from another directory is
|
|
ignored rather than authorizing deletion in the current one.
|
|
|
|
The application can switch games directories while retaining the same state
|
|
directory. Keying ownership only by `game_id` would let provenance learned in
|
|
one tree delete an unrelated same-named file in another tree. The opaque key is
|
|
derived from the platform-native canonical path representation so non-Unicode
|
|
paths do not need a lossy conversion.
|
|
|
|
Alternatives:
|
|
|
|
- Clear every ownership record whenever the setting changes. This is safe, but
|
|
loses useful cleanup history when the user switches back to an earlier tree.
|
|
- Nest ownership records under a games-directory key. This makes the separation
|
|
structural, but complicates all existing per-game state layout and migration.
|
|
- Store the canonical path as JSON text. This is easy to inspect, but cannot
|
|
represent every valid native path without a lossy conversion.
|
|
|
|
## 2026-08-09 — Journal pending download ownership across crashes
|
|
|
|
**TL;DR:** Persist both the last committed file set and a write-ahead pending
|
|
set. Park the old `version.ini` before recording pending ownership, commit the
|
|
new sentinel only after transfer and stale cleanup, then finalize the ledger.
|
|
|
|
A committed-only ledger cannot distinguish a brand-new partial download from
|
|
user files after a crash. The pending set makes cancellation and recovery exact.
|
|
Parking the old sentinel first removes an ambiguity during recovery: while a
|
|
pending set exists, a regular `version.ini` can only be the newly committed
|
|
sentinel. A crash before the pending write instead leaves the parked sentinel,
|
|
which recovery restores.
|
|
|
|
Alternatives:
|
|
|
|
- Record pending before parking the old sentinel. This minimizes time without a
|
|
root sentinel, but recovery cannot tell an old sentinel from a landed commit.
|
|
- Keep only committed ownership. This is smaller, but leaks partial files from a
|
|
crashed first download because they have no trustworthy provenance.
|
|
- Put every downloaded file through a separate staging tree. This gives a clean
|
|
promotion point, but can require another full game's worth of disk and a large
|
|
multi-file transaction mechanism.
|
|
|
|
## 2026-08-09 — Reject untracked exact manifest targets
|
|
|
|
**TL;DR:** Before parking `version.ini` or preparing storage, reject a download
|
|
when a regular-file destination already exists but is absent from the last
|
|
committed ownership set. Previously owned paths may be replaced; every unknown
|
|
path remains untouched.
|
|
|
|
Without prior ownership state, an existing file at an exact manifest target is
|
|
ambiguous. Automatically claiming it would make a failed first or post-upgrade
|
|
download truncate and then delete potentially user-owned bytes. Failing closed
|
|
preserves the file, but a legacy tree without an ownership journal may require
|
|
the user to move or remove conflicting package files before its first update.
|
|
Phase 2 can later recognize intact catalog bytes by their trusted local hashes
|
|
without weakening this boundary.
|
|
|
|
Alternatives:
|
|
|
|
- Treat every validated manifest target as download-owned. This keeps legacy
|
|
upgrades seamless, but can destroy an unrelated user file on failure or
|
|
cancellation.
|
|
- Snapshot and restore untracked targets. This preserves their bytes, but adds
|
|
unbounded backup space and another crash-consistent transaction.
|
|
- Stage every payload in a second game tree and promote it atomically. This
|
|
avoids touching collisions during transfer, but can require another full
|
|
game's worth of disk and a cross-platform multi-file promotion protocol.
|
|
|
|
## 2026-08-09 — Ownership-record rename is its publication point
|
|
|
|
**TL;DR:** Once an ownership-record temporary file has been synced and renamed,
|
|
the application treats it as published. A subsequent parent-directory sync
|
|
failure is returned as a distinct `NeedsRecovery` outcome: callers never roll
|
|
back as though publication failed, and a downloader stops before payload
|
|
mutation.
|
|
|
|
Rolling back after the rename could restore the old `version.ini` beside a
|
|
visible pending record. Recovery would then mistake that old sentinel for the
|
|
new download's commit point. Continuing after a failed directory sync is also
|
|
unsafe: power loss could retain the previous baseline record and later restore
|
|
the old sentinel over mutated payload. The phase-aware result keeps every
|
|
observable state unambiguous and prevents both mistakes.
|
|
|
|
Alternatives:
|
|
|
|
- Log the post-rename sync failure and continue. This avoids disrupting a
|
|
download for a rare filesystem error, but permits mutation without a durable
|
|
write-ahead record.
|
|
- Try to rename the old record back after a sync failure. That adds another
|
|
fallible mutation and can still leave either name visible after a crash.
|
|
- Return one ordinary error for every failure. This is simpler, but callers
|
|
cannot tell whether restoring the old sentinel is safe after the canonical
|
|
record name became visible.
|
|
|
|
## 2026-08-09 — Reject cross-version portable path aliases
|
|
|
|
**TL;DR:** An update is rejected before mutation if an owned path changes only
|
|
by a portable alias, such as `Data.eti` to `data.eti`.
|
|
|
|
On a case-insensitive filesystem, deleting the old spelling after transfer can
|
|
delete the newly written destination. On a case-sensitive filesystem, keeping
|
|
both spellings leaves stale package data. Rejecting the ambiguous transition is
|
|
rare, deterministic, and safe on every supported filesystem.
|
|
|
|
Alternatives:
|
|
|
|
- Compare exact paths only. This correctly removes the old path on Linux, but
|
|
can delete current data on Windows and default macOS filesystems.
|
|
- Treat alias-equivalent paths as identical everywhere. This protects
|
|
case-insensitive filesystems, but leaks the old spelling where both names can
|
|
coexist.
|
|
- Detect filesystem case behavior and rename through a temporary name. This can
|
|
support case-only catalog changes, but adds another crash-consistent mutation
|
|
protocol for a catalog shape the publisher can avoid.
|
|
|
|
## 2026-08-09 — Ownership cleanup preserves all directories
|
|
|
|
**TL;DR:** The download ledger authorizes deletion of exact regular files only;
|
|
cleanup does not prune their now-empty parent directories.
|
|
|
|
An empty directory may have existed before Lanspread placed an owned file in it,
|
|
and the current ledger cannot prove directory provenance. Leaving an empty
|
|
directory is harmless, while deleting a user-created directory violates the
|
|
fail-safe ownership boundary.
|
|
|
|
Alternatives:
|
|
|
|
- Delete every empty ancestor of an owned file. This keeps roots tidy but can
|
|
delete a user-owned empty directory.
|
|
- Journal directory ownership and creation state. This supports exact pruning,
|
|
but expands the crash protocol and still needs a policy for pre-existing
|
|
manifest directories.
|
|
|
|
## 2026-08-09 — Remove downloads as an empty ownership generation
|
|
|
|
**TL;DR:** “Remove downloaded files” parks `version.ini`, durably journals an
|
|
empty pending generation, removes only the last committed file set, discards the
|
|
sentinel, and keeps a valid empty ownership record. It never recursively deletes
|
|
the game root.
|
|
|
|
The existing pending-generation recovery already gives this operation a clean
|
|
crash protocol. Before the empty generation is durable, recovery restores the
|
|
sentinel and no payload has been touched. Afterwards, recovery idempotently
|
|
finishes deleting only proven-owned files and finalizes the empty record.
|
|
Unknown files and directories remain. A legacy, corrupt, or wrongly bound record
|
|
fails closed because paths and sizes alone cannot distinguish package bytes from
|
|
user bytes; Phase 2 can later adopt an intact legacy tree by checking it against
|
|
trusted catalog hashes.
|
|
|
|
Alternatives:
|
|
|
|
- Recursively delete the game root, then clear the ledger. This frees every
|
|
byte, but destroys unknown files and leaves stale deletion authority if the
|
|
process crashes between those operations.
|
|
- Delete the sentinel and leave every ambiguous payload file. This preserves
|
|
user bytes, but reports a misleading successful removal while reclaiming
|
|
almost no space.
|
|
- Infer ownership from the current remote manifest or filename extensions. This
|
|
is convenient for legacy trees, but lets untrusted or incomplete metadata
|
|
authorize deletion.
|
|
|
|
## 2026-08-09 — Ownership follows committed paths, not inode generations
|
|
|
|
**TL;DR:** A successfully committed download path remains downloader-owned until
|
|
a later download or explicit removal releases it. The ledger does not persist
|
|
platform-specific inode or file-ID generations.
|
|
|
|
This is enough for the stated remote-peer threat model: a peer cannot replace a
|
|
victim filesystem object except through the one admitted download operation, and
|
|
untracked exact targets are rejected before that operation starts. A local actor
|
|
who replaces an already owned path makes the replacement subject to later
|
|
owned-path cleanup. That local-filesystem race is outside the plan's threat
|
|
model, but the consequence is recorded because the provenance boundary is path
|
|
based rather than object based.
|
|
|
|
The same rule applies if someone deletes and recreates the entire game root at
|
|
the same configured path outside Lanspread: the last committed relative paths
|
|
remain owned. The application cannot distinguish that replacement without a
|
|
separate root-generation marker or platform file identity.
|
|
|
|
Alternatives:
|
|
|
|
- Persist inode/file-ID generations and delete only the exact recorded object.
|
|
This detects replacement, but creates a platform-specific schema and does not
|
|
survive ordinary copy/restore workflows consistently.
|
|
- Hash every owned file before cleanup. Phase 2 catalog hashes can identify
|
|
intact package content, but always rereading multi-gigabyte payloads solely
|
|
for deletion adds significant latency and still needs a policy for modified
|
|
downloader-owned files.
|
|
- Never delete a previously owned path automatically. This preserves every local
|
|
replacement but makes stale cleanup and “Remove downloaded files” unable to
|
|
reclaim ordinary package data.
|
|
|
|
## 2026-08-09 — A baseline record distinguishes new and legacy scratch
|
|
|
|
**TL;DR:** Before parking an existing `version.ini`, the new downloader durably
|
|
writes at least an empty ownership record. Recovery restores a parked sentinel
|
|
only when that valid baseline exists; scratch beside a missing or invalid record
|
|
is discarded without making payload bytes ready.
|
|
|
|
Older builds used `.version.ini.discarded` but could leave it behind after they
|
|
had already truncated or overwritten payload files. The scratch filename alone
|
|
therefore cannot prove that mutation never began. The baseline makes every park
|
|
performed by the new transaction format distinguishable.
|
|
|
|
Alternatives:
|
|
|
|
- Restore every discarded sentinel when no journal exists. This preserves a
|
|
clean crash between park and journaling, but can advertise a legacy partial
|
|
payload as a complete game.
|
|
- Never restore a discarded sentinel. This is fail-safe for upgrades, but would
|
|
unnecessarily discard a known-clean sentinel after a new-format pre-journal
|
|
crash.
|
|
- Introduce another dedicated phase-marker file. This is equally expressive, but
|
|
adds a second persistent transaction artifact where an empty valid ledger
|
|
already provides the needed proof.
|
|
|
|
## 2026-08-09 — Treat the configured games directory as the capability root
|
|
|
|
**TL;DR:** Canonicalize the user-selected games directory once, open the game as
|
|
one direct non-link child, and perform every download, sentinel, and ownership
|
|
mutation relative to that retained directory handle. The configured directory
|
|
itself is the trust anchor; existing links in its absolute parent path are not
|
|
re-walked and rejected.
|
|
|
|
This matches the product setting: the user chooses one library directory, while
|
|
remote descriptions control only descendants of a known catalog game. Rejecting
|
|
the game root and every descendant link/reparse component closes the peer-driven
|
|
escape without redefining whether the configured library path may itself be a
|
|
platform alias or mounted location.
|
|
|
|
Alternatives:
|
|
|
|
- Reject every link in every absolute ancestor of the configured directory. This
|
|
is stricter, but rejects common user-selected paths and requires
|
|
platform-specific absolute-path walking outside the remote peer's authority.
|
|
- Keep one capability from application startup through every settings change.
|
|
This minimizes ambient path resolution, but considerably expands lifecycle and
|
|
settings coordination for no additional peer-controlled path component.
|
|
- Revalidate strings before each ordinary path operation. This is simpler, but
|
|
remains vulnerable to check-then-use swaps and reparse behavior.
|
|
|
|
## 2026-08-09 — Retain one root handle and reopen each transfer file safely
|
|
|
|
**TL;DR:** Keep one capability handle for the game root, but open each directory
|
|
component and final file without following links for preparation, each chunk,
|
|
durability checks, and cleanup. Do not retain a handle for every manifest file.
|
|
|
|
A manifest may contain 100,000 entries. Retaining all file handles would make
|
|
descriptor exhaustion part of ordinary planning. Reopening from the stable root
|
|
keeps resource use bounded; each chunk writes and verifies through the exact
|
|
handle it opened, so a later path swap cannot redirect that write.
|
|
|
|
Alternatives:
|
|
|
|
- Retain every destination handle for the full download. This gives the
|
|
strongest object identity, but can exhaust process and system handle limits.
|
|
- Retain one handle per active chunk. This is feasible, but still needs the same
|
|
no-follow reopen walk and does not simplify preparation or recovery.
|
|
- Use absolute paths after initial validation. This uses fewer abstractions, but
|
|
reintroduces the link/reparse race the confinement phase exists to remove.
|
|
|
|
## 2026-08-09 — Do not overstate Windows power-loss durability
|
|
|
|
**TL;DR:** Sync payload and sentinel file handles on every platform and sync
|
|
directory handles where the safe Rust platform API supports it. On Windows,
|
|
retain the unambiguous process-crash recovery protocol but leave power-loss
|
|
durability as an explicit real-NTFS gate rather than claiming proof from Linux.
|
|
|
|
Rust does not provide a portable guaranteed directory flush, and this crate
|
|
forbids unsafe code. Silently calling a Unix-only directory `sync_all`
|
|
equivalent would turn an unverified assumption into a false cross-platform
|
|
guarantee. The Phase 1 Windows gate therefore still needs a supported Windows
|
|
run covering reparse points, rename recovery, and actual filesystem behavior.
|
|
|
|
Alternatives:
|
|
|
|
- Add a small platform-specific safe wrapper crate around Windows directory
|
|
handles and `FlushFileBuffers`. This may establish stronger durability, but it
|
|
adds native code and still requires real NTFS failure evidence.
|
|
- Fail every Windows download because directory durability is not portable. This
|
|
is fail-closed but makes a supported product platform unusable.
|
|
- Treat successful file sync and rename as proven power-loss durability. This is
|
|
convenient, but is not evidence and directly violates the plan's reporting
|
|
boundary.
|
|
|
|
## 2026-08-09 — Do not make hard-link identity part of remote confinement
|
|
|
|
**TL;DR:** Reject links and reparse points that can redirect path resolution,
|
|
but do not reject an otherwise regular manifest target merely because it has
|
|
multiple hard links. The threat model excludes an attacker controlling the
|
|
victim filesystem, and only previously recorded download-owned targets may be
|
|
replaced by a transaction.
|
|
|
|
A hard link cannot be selected or created by a remote description outside the
|
|
validated game-relative namespace. A local user can make an already owned inode
|
|
visible under another name, but that is local filesystem manipulation rather
|
|
than a peer-controlled path escape. New untracked manifest targets are rejected
|
|
before mutation instead of being claimed automatically.
|
|
|
|
Alternatives:
|
|
|
|
- Reject every existing destination whose link count exceeds one. This better
|
|
protects local aliases, but can block legitimate deduplicated or legacy game
|
|
trees and needs consistent evidence on every supported filesystem.
|
|
- Copy an existing multiply linked file to a private inode before mutation. This
|
|
preserves the other name, but silently consumes space and adds another
|
|
fallible pre-transfer mutation.
|
|
- Track inode identities in the ownership ledger. This can detect later
|
|
replacement, but makes persistent state platform-specific and still cannot
|
|
prevent a local actor from changing links concurrently.
|