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

20 KiB

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.

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.