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
This commit is contained in:
@@ -140,25 +140,27 @@ Alternatives:
|
||||
|
||||
**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 logged but is not reported as a pre-publication failure.
|
||||
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. Treating rename as publication keeps every
|
||||
observable state unambiguous; the parent sync still runs to improve power-loss
|
||||
durability.
|
||||
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:
|
||||
|
||||
- Return a phase-aware error that forces every caller to distinguish failures
|
||||
before and after rename. This is explicit, but spreads a subtle transaction
|
||||
protocol across all ownership callers without changing their post-rename
|
||||
action.
|
||||
- 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.
|
||||
- Treat every sync failure as fatal and leave the new record in place. This
|
||||
sounds stricter, but callers could then perform the unsafe sentinel rollback
|
||||
unless the error also carries publication state.
|
||||
- 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
|
||||
|
||||
@@ -222,3 +224,99 @@ Alternatives:
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user