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)
This commit is contained in:
2026-08-09 19:46:10 +02:00
parent 691176e1d5
commit 08b1cb5c1d
10 changed files with 630 additions and 301 deletions
@@ -114,27 +114,31 @@ Alternatives:
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
## 2026-08-09 — Reject untracked exact manifest targets
**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.
**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. 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.
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:
- Reject every untracked existing target. This preserves ambiguous files, but
prevents the first post-upgrade update of existing legacy downloads.
- 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.
- 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.
- 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
@@ -201,6 +205,66 @@ Alternatives:
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
@@ -299,15 +363,14 @@ Alternatives:
**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.
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 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.
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: