Files
lanspread/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md
T
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

323 lines
16 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 — Manifest target paths become download-owned
**TL;DR:** Once a validated download transaction starts, every regular file path
in that manifest is treated as downloader-owned for abort and recovery. Unknown
paths outside the manifest remain untouched.
Without prior ownership state, an existing file at an exact manifest target is
ambiguous. Treating the authoritative target namespace as download-owned keeps
legacy upgrades working and makes partial-transfer cleanup deterministic. A
user-created extra file is preserved, but a user file placed at an exact package
path may be replaced or removed by the download.
Alternatives:
- Reject every untracked existing target. This preserves ambiguous files, but
prevents the first post-upgrade update of existing legacy downloads.
- Snapshot and restore untracked targets. This preserves their bytes, but adds
unbounded backup space and another crash-consistent transaction.
- Leave pre-existing untracked targets after abort. This avoids deletion, but
the transfer may already have truncated or partially overwritten them, so it
does not actually preserve their contents.
## 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 — 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 exact manifest target paths already become download-owned
when a transaction starts.
A hard link cannot be selected or created by a remote description outside the
validated game-relative namespace. A local user can make the same inode visible
under another name, but that is local filesystem manipulation rather than a
peer-controlled path escape. This choice inherits the documented consequence
that replacing an exact ambiguous manifest target may affect another local name
for those bytes.
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.