merge: Campaign LA LA10 - updater review-closed
This commit is contained in:
commit
da4fb3de19
40 changed files with 9778 additions and 68 deletions
|
|
@ -471,13 +471,230 @@ gate (user): clean-profile first-run against real DATs.
|
|||
verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json`
|
||||
pointer swap, refuse while any session runs, keep previous version for
|
||||
one-step rollback.
|
||||
- Launcher self-update: staged download + rename-dance on next start.
|
||||
- Launcher self-update: staged download + target-local atomic replacement on
|
||||
next start.
|
||||
- Session-config composition targets `app/current`'s binaries.
|
||||
|
||||
**Acceptance:** manifest/download/verify/swap tests against a local HTTP
|
||||
fixture; rollback test; refusal-while-running test; self-update staging test;
|
||||
suites green. Connected gate (user): staged-manifest update swap end-to-end.
|
||||
|
||||
### Pinned updater contracts (v1, BINDING)
|
||||
|
||||
This section is the single source of truth for every LA10 feed and on-disk
|
||||
shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject
|
||||
unknown or duplicate properties, and reject unsupported schema versions
|
||||
before doing network, extraction, or activation work.
|
||||
|
||||
The production feed is pinned to GitHub owner/repository
|
||||
`eriknihlen/acdream`; the launcher reads
|
||||
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
|
||||
Tests use a separate internal fixture constructor that may admit loopback HTTP;
|
||||
that allowance never propagates to the production feed. Production manifest
|
||||
and artifact URIs use HTTPS. Automatic redirects are disabled and every
|
||||
redirect hop is validated before it is requested; redirect loops, a chain over
|
||||
five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"version": "1.2.3",
|
||||
"minimumLauncherVersion": "1.1.0",
|
||||
"clients": {
|
||||
"win-x64": {
|
||||
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip",
|
||||
"sha256": "<64 hex characters>",
|
||||
"size": 123
|
||||
}
|
||||
},
|
||||
"launchers": {
|
||||
"win-x64": {
|
||||
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip",
|
||||
"sha256": "<64 hex characters>",
|
||||
"size": 123
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build
|
||||
metadata is ignored for precedence; numeric identifiers are compared without
|
||||
fixed-width integer overflow. RID keys are exact lowercase portable RIDs.
|
||||
Both dictionaries are required and the running RID must have a client and a
|
||||
launcher row. Artifact sizes are positive and capped by the launcher's
|
||||
download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute.
|
||||
Client ZIPs have the two host executables at their root
|
||||
(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have
|
||||
`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists.
|
||||
|
||||
Every extracted client version has
|
||||
`DataDirectory/app/<version>/install.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"version": "1.2.3",
|
||||
"rid": "win-x64",
|
||||
"archiveSha256": "<64 hex characters>",
|
||||
"archiveSize": 123,
|
||||
"files": [
|
||||
{ "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Paths use `/`, are relative, normalized, unique under ordinal-ignore-case,
|
||||
and sorted ordinally. `unixMode` contains only the portable permission bits
|
||||
captured from the ZIP entry. Startup verifies every recorded regular file by
|
||||
size/SHA, rejects unrecorded files/reparse points, and requires the two host
|
||||
executables before admitting a version. Extraction uses a random sibling
|
||||
directory under `DataDirectory/app/`; promotion to `<version>/` is one
|
||||
same-volume directory rename.
|
||||
|
||||
`DataDirectory/app/current.json` is the only activation authority:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" }
|
||||
```
|
||||
|
||||
`previousVersion` is omitted for the first activation. Pointer writes are
|
||||
write-through temporary-file + same-directory atomic rename. The last valid
|
||||
pointer is also atomically preserved as `current.previous.json`; startup may
|
||||
restore that exact backup only when `current.json` is missing/malformed and
|
||||
the referenced version verifies. Orphan LA10 staging directories, download
|
||||
archives, corrupt-version quarantine directories, and pointer temporaries are
|
||||
transaction-owned by exact lowercase GUID names and are removed only under the
|
||||
exclusive update lease; near-matching user names are preserved. A corrupt
|
||||
installed version is never silently selected; the explicit one-step rollback
|
||||
swaps the two verified pointer versions.
|
||||
|
||||
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
|
||||
supervised launcher activity holds a shared OS handle from before executable
|
||||
resolution until terminal process observation; launcher disposal requests
|
||||
child termination and does not release that handle until the child is actually
|
||||
observed terminal. An update/rollback holds the
|
||||
exclusive handle for its entire recovery/download/extract/promote/pointer
|
||||
transaction. Failure to acquire the exclusive handle is an immediate refusal,
|
||||
not a wait behind a running session. The open handle, not lock-file contents,
|
||||
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 3):
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"transactionId": "0123456789abcdef0123456789abcdef",
|
||||
"state": "staged",
|
||||
"version": "1.2.3",
|
||||
"rid": "win-x64",
|
||||
"targetDirectory": "<absolute current launcher directory>",
|
||||
"archiveSha256": "<64 hex characters>",
|
||||
"archiveSize": 123,
|
||||
"files": [
|
||||
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
|
||||
],
|
||||
"apply": null
|
||||
}
|
||||
```
|
||||
|
||||
Before mutation the verified staged launcher becomes the next-start helper and
|
||||
waits for the initiating launcher PID without invoking a shell. It first copies
|
||||
the complete verified payload into the target-local
|
||||
`.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
|
||||
to `applying`; `apply` is an ordinally sorted union of new payload paths, the
|
||||
owned metadata path, and obsolete paths from the previous ownership record:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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 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.
|
||||
|
||||
Successful application writes strict target ownership metadata at
|
||||
`<launcher directory>/launcher.install.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"version": "1.2.3",
|
||||
"rid": "win-x64",
|
||||
"files": [
|
||||
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The archive may not supply that reserved metadata path. A prior valid record is
|
||||
the only authority for obsolete-file removal; the first managed update does
|
||||
not infer ownership of unrelated legacy files. On success the plan becomes
|
||||
`awaitingConfirmation`; the new launcher confirms at its first managed
|
||||
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
|
||||
under pinned roots; the target directory must equal the actual launcher base
|
||||
directory.
|
||||
|
||||
Every portable archive and persisted relative path rejects Windows device
|
||||
segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`,
|
||||
`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript
|
||||
forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
|
||||
|
||||
## LA11 — closeout
|
||||
|
||||
- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue