fix(launcher): verify self-update rollback sources

This commit is contained in:
Erik 2026-08-14 23:41:55 +02:00
parent 1955ca8ab5
commit 09d84387a8
6 changed files with 949 additions and 103 deletions

View file

@ -580,11 +580,11 @@ owns the lease and therefore releases after process death.
Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
durable authority is `DataDirectory/launcher-update/pending.json` (schema 2):
durable authority is `DataDirectory/launcher-update/pending.json` (schema 3):
```json
{
"schemaVersion": 2,
"schemaVersion": 3,
"transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged",
"version": "1.2.3",
@ -608,18 +608,54 @@ owned metadata path, and obsolete paths from the previous ownership record:
```json
[
{ "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true },
{ "path": "obsolete.dll", "operation": "remove", "hadOriginal": true }
{
"path": "acdream-launcher.exe",
"operation": "install",
"hadOriginal": true,
"priorSha256": "<64 hex characters>",
"priorSize": 123,
"priorUnixMode": 0,
"replacementSha256": "<64 hex characters>",
"replacementSize": 456,
"replacementUnixMode": 0
},
{
"path": "new-support.dat",
"operation": "install",
"hadOriginal": false,
"priorSha256": null,
"priorSize": null,
"priorUnixMode": null,
"replacementSha256": "<64 hex characters>",
"replacementSize": 456,
"replacementUnixMode": 0
}
]
```
Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and
Linux mode bits; a no-original entry has all three prior fields null. Every
install entry likewise persists the verified replacement metadata, while a
remove entry has all three replacement fields null. The journal is invalid
unless those fields agree with `hadOriginal` and `operation`.
Existing targets are replaced with one same-filesystem atomic replace whose
backup is also target-local. Previously absent noncanonical files use one
same-filesystem rename; obsolete owned files use one rename into backup. The
canonical launcher path therefore contains either the complete old file or the
complete new file at every durable crash boundary. Rollback reverses the same
operations atomically and is idempotent after a process/power loss. Linux mode
bits come from the verified incoming file. A helper that cannot immediately
complete new file at every durable crash boundary. Rollback first performs a
zero-mutation preflight of the complete target-local transaction and every
journal entry. It rejects reparse points, unsafe parents, unrecorded paths,
ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior,
incoming, or discard file. Only a fully preflighted rollback may atomically
restore backups; newly created files move to target-local discard rather than
being deleted. The complete prior target set is then reverified before the
plan enters durable `rolledBack` state while retaining the journal. Retry is
allowed only after that prior set is reverified again and the plan returns to
`staged`. Thus rollback is atomic per file and idempotent after a process/power
loss. Any ambiguity preserves the applying plan and transaction evidence and
forbids launching the canonical path for manual recovery. Linux mode bits come
from the verified incoming file. A helper that cannot immediately
acquire the exclusive update lease defers the staged plan and exits without
restarting the old launcher, preventing restart loops.
@ -645,6 +681,9 @@ instruction, after which the helper releases its lease and the confirmed
launcher reclaims plan, data-transaction, and target-local residue. An
`applying` plan is rolled back before retry, and failure to start/confirm the
new launcher restores every original (and removes every no-original target).
The helper restarts the restored canonical launcher only after a fresh complete
verification of the retained `rolledBack` journal; rollback corruption or an
unsafe backup/discard tree exits without starting either launcher.
Reading `pending.json` never performs cleanup. Ordinary startup attempts the
exclusive lease without waiting and skips update cleanup entirely when another
session/staging transaction owns it. All plan paths are re-derived/contained

View file

@ -305,7 +305,7 @@ preview would be a deliberate divergence we are NOT taking.
The exact v1 manifest, extracted-version record, `current.json` activation
pointer and launcher ownership record, shared-session/exclusive-update OS
lease, and durable self-update plan schema 2 are pinned in
lease, and durable self-update plan schema 3 are pinned in
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
contracts (v1, BINDING)**. That section is normative: implementations reject
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0