Skip to content

Commit b33e4d9

Browse files
VortexUKclaude
andcommitted
v0.1.12: one-click self-update with SHA-256 verify + on-exit swap
User clicks "Install update" in the version banner → plugin downloads the new DLL, verifies SHA-256, stages a .new file next to the loaded one, and spawns a hidden PowerShell helper that swaps the file once ACT exits. User effort: one click + restart ACT. ────────────────────────────────────────────────────────────────── Why this design — the constraint that shapes everything else: Windows holds an exclusive lock on a loaded .NET DLL for the process lifetime. The plugin can't overwrite its own DLL from inside ACT. Workaround is "write to <dll>.new, then atomic-rename once the process exits". The rename has to be done by something OTHER than the plugin (because the plugin dies with ACT), so we spawn a detached PowerShell job that Wait-Process'es on ACT's PID and does the Move-Item once it returns. ────────────────────────────────────────────────────────────────── New pieces: * src/Core/UpdateChecker.cs — ParseLatestDllAsset extracts the browser_download_url + digest from the GH release feed. Strips the "sha256:" prefix, returns ("","") on any missing piece. * src/Core/UpdateStatus.cs — UpdateCheckResult gains LatestDllUrl + LatestDllSha256. Empty digest = installer refuses to stage, user falls back to browser. * src/Core/PluginUpdater.cs — pure download + verify. Refuses without a digest, on size overflow (10 MB cap vs ~76 KB DLL), on any hash mismatch. Constant-time hex compare. * src/UpdateInstaller.cs — UI-side staging. Writes <dll>.new, drops EQ2Lexicon.ACTPlugin.UPDATE_READ_ME.txt with manual recovery steps, spawns the hidden PowerShell helper. If the helper spawn fails (no PowerShell, restricted exec policy, AV blocking child procs) the staged file + readme are still there so the user can complete the swap by hand. UI changes (SettingsPanel): * Version banner now has TWO buttons: "Install update" (primary) + "Download in browser" (secondary). Install only shown when we have both URL and digest; Download always available as the fallback for any failure case. * New italic status line below the buttons shows "Downloading…" → "Verified. Staging…" → "Update staged. Close and reopen ACT to apply" — or a clear error. * Successful stage disables both buttons so the user can't re-stage in the same session. Plugin.BeginAutoUpdateAsync orchestrates the pipeline. All exceptions caught and surfaced via SetUpdateInstallStatus — clicking Install must never crash ACT or leave the UI in a half-disabled state. Tests: 10 new PluginUpdaterTests pinning the verify branch (happy path, hash mismatch, no-digest refusal, oversized rejection, fetcher exceptions, case-insensitive hash compare). 5 new UpdateChecker tests for ParseLatestDllAsset (real GH shape, draft skip, non-DLL asset skip, missing digest, case- insensitive prefix). Total 174/174. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1cf2f74 commit b33e4d9

11 files changed

Lines changed: 1017 additions & 15 deletions

CLAUDE.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ The split also unlocks GitHub Actions CI: the workflow at `.github/workflows/ci.
3535
| `src/Core/Snapshots.cs` | Plain DTOs (`EncounterSnapshot`, `CombatantSnapshot`, `DamageTypeAggregate`, `AttackTypeSnapshot`) that mirror the slice of ACT's data model the payload builder reads. |
3636
| `src/Core/PluginConfig.cs` | User settings persisted as XML. `Load(path)` and `Save(path)` take the path from the caller (resolved in `Plugin.cs` via `ActHelpers.GetConfigPath()`). API token is DPAPI-encrypted on disk (current-user scope) with a `DPAPI:` prefix; legacy plaintext from v0.1.0–v0.1.4 still loads. |
3737
| `src/Core/UploadClient.cs` | `HttpClient` wrapper. `TestConnectionAsync` (GET `/api/auth/whoami`), `UploadEncounterAsync` (POST `/api/parses/ingest`). `ValidateServerUrl` rejects non-https except `localhost`/`127.0.0.1`/`[::1]`. Adds `X-Lexicon-Signature` HMAC header to every upload (see PayloadSigner) and blocks uploads when `UpdateStatus.UploadBlocked == true`. |
38-
| `src/Core/UpdateChecker.cs` | GitHub-releases-feed fetcher + pure version-comparison logic. `Compute(currentVersion, releasedVersions)` returns an `UpdateCheckResult` with `Current`/`SlightlyStale`/`TooOld`/`DevBuild`/`Unknown`. Threshold = `StaleThresholdVersions` (2) — older than that blocks uploads. Failure modes (network down, GH 5xx, rate-limit) all collapse to `Unknown` so the gate fails OPEN. |
38+
| `src/Core/UpdateChecker.cs` | GitHub-releases-feed fetcher + pure version-comparison logic. `Compute(currentVersion, releasedVersions)` returns an `UpdateCheckResult` with `Current`/`SlightlyStale`/`TooOld`/`DevBuild`/`Unknown`. Threshold = `StaleThresholdVersions` (2) — older than that blocks uploads. Failure modes (network down, GH 5xx, rate-limit) all collapse to `Unknown` so the gate fails OPEN. `ParseLatestDllAsset` extracts the latest-release DLL URL + SHA-256 digest for the self-update flow. |
39+
| `src/Core/PluginUpdater.cs` | Download + SHA-256 verify for the self-update flow (v0.1.12+). Pure (`IDllAssetFetcher` injects HTTP). Constant-time hash compare. Refuses to install without a digest, on size overflow (10 MB cap vs ~76 KB DLL), and on any hash mismatch. |
40+
| `src/UpdateInstaller.cs` | UI-side staging for the self-update flow. Writes `<dll>.new` next to the running DLL, drops a manual-recovery readme, spawns a hidden PowerShell `Wait-Process -Id <act_pid>; Move-Item -Force *.new *.dll` that swaps the file once ACT exits. Recovery readme covers the case where the helper script gets killed by AV. |
3941
| `src/Core/PayloadSigner.cs` | HMAC-SHA256 of the upload body keyed by the user's API token. See "Payload integrity" below for the threat model and what this does/doesn't defend. |
4042
| `tests/EQ2Lexicon.ACTPlugin.Tests/` | xUnit project, `net48`. 72 tests. References Core only (UI types aren't testable without ACT). Covers PayloadBuilder, PluginConfig (incl. DPAPI roundtrip), UploadClient (incl. URL validator). Coverage collected via `coverlet.collector`. |
4143

@@ -144,6 +146,21 @@ Failure modes (offline, GitHub 5xx, rate-limit, malformed JSON) all collapse to
144146

145147
No caching of the GitHub response — request volume is at most a handful per user per day, well under the unauthenticated 60/h/IP limit, and re-fetching on every ACT start means a user who just installed an update sees the banner clear instantly after restarting ACT.
146148

149+
## Self-update (v0.1.12+)
150+
151+
The version banner gets a primary "Install update" button (alongside the existing "Download in browser" secondary fallback) when both an asset URL and a SHA-256 digest were resolved from the GitHub release feed. Click flow:
152+
153+
1. **Download** the DLL bytes from `LatestDllUrl` via HttpClient. Capped at 10 MB; refused on any non-2xx response. Bytes held in memory only.
154+
2. **Verify** SHA-256 against `LatestDllSha256` (pulled from the release feed's `digest` field) via constant-time compare. Mismatch → refuse, surface error, don't touch disk. **No digest** → also refuse — shipping unverified code into a running .NET process is unacceptable, the user gets nudged to the browser fallback.
155+
3. **Stage** by writing the bytes to `EQ2Lexicon.ACTPlugin.dll.new` next to the loaded DLL. (Windows allows creating this file; it does NOT allow overwriting the loaded `.dll` itself — that's why we don't just overwrite.)
156+
4. **Drop a recovery readme** (`EQ2Lexicon.ACTPlugin.UPDATE_READ_ME.txt`) in the same folder with manual swap instructions, in case the helper script fails.
157+
5. **Spawn a hidden PowerShell helper**: `Wait-Process -Id <act_pid>; Move-Item -Force *.new *.dll`. Sleeps in the background until ACT exits, then atomic-renames. User sees nothing.
158+
6. **UI message**: "Update staged. Close and reopen ACT to apply — the swap happens automatically." Buttons disable.
159+
160+
User effort: one click + restart ACT whenever convenient. The Railway URL fallback covers users who don't restart for days — the v0.1.12 staging sits there, the v0.1.11 keeps uploading via parses.eq2lexicon.com (same Railway service), no breakage.
161+
162+
**What it doesn't try to do**: relaunch ACT itself. Spawning a "wait then re-launch host" helper from a plugin is the kind of behaviour that gets flagged by AV. The user restart is small friction; the auto-relaunch isn't worth the suspicion budget.
163+
147164
## Language assumptions (English-only EQ2)
148165

149166
The plugin assumes English ACT EQ2-parser output throughout. ACT does have German + French EQ2 parser plugins on record, but as of 2026 there are **no active non-English EQ2 servers** — the EU/JP servers (Storms = French, Valor = German, Sebilis = Japanese) were consolidated into the English-only Thurgadin server in 2016, and the current TLE servers (Varsoon, Kaladim) are English-only. So this assumption is safe today; documenting it so future-you doesn't have to re-research it.

src/Core/EQ2Lexicon.ACTPlugin.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Build metadata is mirrored from the UI csproj so this assembly's
1919
version tracks the user-facing plugin version.
2020
-->
21-
<Version>0.1.11</Version>
21+
<Version>0.1.12</Version>
2222
<Company>EQ2 Lexicon</Company>
2323
<Product>EQ2 Lexicon Uploader (Core)</Product>
2424
<Description>Pure payload / config / HTTP layer shared by EQ2Lexicon.ACTPlugin.</Description>

src/Core/PluginUpdater.cs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Security.Cryptography;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace EQ2Lexicon.ACTPlugin
8+
{
9+
/// <summary>
10+
/// Outcome of a download + verify attempt. Plain DTO so Plugin and
11+
/// the UI can pattern-match without a sea of out-parameters.
12+
/// </summary>
13+
public class DownloadResult
14+
{
15+
/// <summary>True iff bytes were fetched AND hash matched.</summary>
16+
public bool Success { get; set; }
17+
18+
/// <summary>The verified DLL bytes when <see cref="Success"/> is true; null otherwise.</summary>
19+
public byte[]? DllBytes { get; set; }
20+
21+
/// <summary>Short user-facing message — what to show in the SettingsPanel.</summary>
22+
public string Message { get; set; } = "";
23+
}
24+
25+
/// <summary>
26+
/// Abstracts the binary fetch so unit tests can supply canned bytes
27+
/// without going to the network. Symmetrical with
28+
/// <see cref="IReleaseFeedFetcher"/>.
29+
/// </summary>
30+
public interface IDllAssetFetcher
31+
{
32+
Task<byte[]> FetchAsync(string url);
33+
}
34+
35+
/// <summary>
36+
/// Production fetcher — wraps HttpClient and applies the same
37+
/// User-Agent contract the rest of the plugin uses.
38+
/// </summary>
39+
public class HttpDllAssetFetcher : IDllAssetFetcher
40+
{
41+
private readonly HttpClient _http;
42+
private readonly string _userAgent;
43+
44+
public HttpDllAssetFetcher(HttpClient http, string userAgent)
45+
{
46+
_http = http;
47+
_userAgent = string.IsNullOrWhiteSpace(userAgent) ? "EQ2LexiconACTPlugin" : userAgent;
48+
}
49+
50+
public async Task<byte[]> FetchAsync(string url)
51+
{
52+
using (var req = new HttpRequestMessage(HttpMethod.Get, url))
53+
{
54+
req.Headers.UserAgent.ParseAdd(_userAgent);
55+
using (var resp = await _http.SendAsync(req).ConfigureAwait(false))
56+
{
57+
if (!resp.IsSuccessStatusCode)
58+
{
59+
throw new HttpRequestException(
60+
$"HTTP {(int)resp.StatusCode} {resp.StatusCode} when fetching {url}");
61+
}
62+
return await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
63+
}
64+
}
65+
}
66+
}
67+
68+
/// <summary>
69+
/// Pure download + verify orchestration for the self-update flow.
70+
/// Lives in Core because the verification logic (SHA-256, constant-
71+
/// time compare, size cap) is exactly the kind of thing that
72+
/// silently regresses without tests.
73+
///
74+
/// The actual on-disk staging happens in <c>UpdateInstaller</c>
75+
/// (UI assembly — needs Process.Start, etc.) because Core can't
76+
/// know where the running DLL is or how to spawn the swap helper.
77+
/// </summary>
78+
public static class PluginUpdater
79+
{
80+
/// <summary>
81+
/// Hard cap on the downloaded DLL size. Real DLL is ~76 KB; a
82+
/// 10 MB ceiling absorbs years of growth and still rejects a
83+
/// hostile substitution that would balloon the file (e.g. a
84+
/// CDN-cached different binary).
85+
/// </summary>
86+
public const int MaxDllBytes = 10 * 1024 * 1024;
87+
88+
/// <summary>
89+
/// Download bytes from <paramref name="url"/>, compute SHA-256,
90+
/// constant-time-compare against <paramref name="expectedSha256Hex"/>.
91+
/// Returns a <see cref="DownloadResult"/> — caller decides
92+
/// what to do with success/failure (almost certainly: stage on
93+
/// disk or display the error message).
94+
///
95+
/// Hard rule: <paramref name="expectedSha256Hex"/> must be
96+
/// non-empty (64 lowercase hex chars). We refuse to download
97+
/// without a digest to compare against — shipping unverified
98+
/// code into the user's ACT process would be an own-goal.
99+
/// </summary>
100+
public static async Task<DownloadResult> DownloadAndVerifyAsync(
101+
string url,
102+
string expectedSha256Hex,
103+
IDllAssetFetcher fetcher)
104+
{
105+
if (string.IsNullOrWhiteSpace(url))
106+
return new DownloadResult { Message = "No download URL available." };
107+
if (string.IsNullOrWhiteSpace(expectedSha256Hex))
108+
return new DownloadResult { Message = "No SHA-256 digest published for this release — refusing to auto-install." };
109+
if (fetcher == null)
110+
return new DownloadResult { Message = "Internal: fetcher missing." };
111+
112+
byte[] bytes;
113+
try
114+
{
115+
bytes = await fetcher.FetchAsync(url).ConfigureAwait(false);
116+
}
117+
catch (Exception ex)
118+
{
119+
return new DownloadResult { Message = "Download failed: " + ex.Message };
120+
}
121+
122+
if (bytes == null || bytes.Length == 0)
123+
return new DownloadResult { Message = "Download returned no bytes." };
124+
if (bytes.Length > MaxDllBytes)
125+
return new DownloadResult
126+
{
127+
Message = $"Downloaded file too large ({bytes.Length} bytes; cap is {MaxDllBytes}).",
128+
};
129+
130+
string actualHex;
131+
using (var sha = SHA256.Create())
132+
{
133+
actualHex = ToLowerHex(sha.ComputeHash(bytes));
134+
}
135+
var expected = expectedSha256Hex.Trim().ToLowerInvariant();
136+
if (!ConstantTimeEquals(actualHex, expected))
137+
{
138+
return new DownloadResult
139+
{
140+
Message = $"SHA-256 mismatch (expected {expected.Substring(0, 12)}…, got {actualHex.Substring(0, 12)}…). " +
141+
"Refusing to install a tampered or corrupt download.",
142+
};
143+
}
144+
145+
return new DownloadResult
146+
{
147+
Success = true,
148+
DllBytes = bytes,
149+
Message = $"Downloaded {bytes.Length} bytes, SHA-256 verified.",
150+
};
151+
}
152+
153+
private static string ToLowerHex(byte[] bytes)
154+
{
155+
var sb = new StringBuilder(bytes.Length * 2);
156+
for (int i = 0; i < bytes.Length; i++)
157+
{
158+
sb.Append(bytes[i].ToString("x2"));
159+
}
160+
return sb.ToString();
161+
}
162+
163+
/// <summary>
164+
/// Length-then-bitwise compare. Length difference short-circuits
165+
/// (and is itself not secret — hashes are fixed-width) but the
166+
/// per-char compare never short-circuits, so timing doesn't leak
167+
/// which prefix matched. Probably overkill for "did the download
168+
/// get tampered with" but free and correct.
169+
/// </summary>
170+
private static bool ConstantTimeEquals(string a, string b)
171+
{
172+
if (a == null || b == null) return false;
173+
if (a.Length != b.Length) return false;
174+
int diff = 0;
175+
for (int i = 0; i < a.Length; i++)
176+
{
177+
diff |= a[i] ^ b[i];
178+
}
179+
return diff == 0;
180+
}
181+
}
182+
}

src/Core/UpdateChecker.cs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,111 @@ public static List<Version> ParseReleaseVersions(string json)
139139
return result;
140140
}
141141

142+
/// <summary>
143+
/// Walk the GitHub release feed for the FIRST non-draft release
144+
/// (newest published) and pull out the URL + SHA-256 digest of
145+
/// the EQ2Lexicon.ACTPlugin.dll asset attached to it.
146+
///
147+
/// Used by the self-update flow (v0.1.12+) — the URL feeds
148+
/// HttpClient.GetByteArrayAsync, the digest feeds
149+
/// PluginUpdater's hash verification.
150+
///
151+
/// Returns empty strings when:
152+
/// * JSON is empty / malformed
153+
/// * no non-draft release exists
154+
/// * the release has no asset matching "*.ACTPlugin.dll"
155+
/// * the asset has no `digest` field (very recent GitHub
156+
/// releases occasionally surface before the digest backfill
157+
/// completes) — the installer must refuse to auto-stage
158+
/// in that case rather than ship an unverified binary
159+
///
160+
/// Parser is a small hand-rolled walk over the JSON shape rather
161+
/// than full deserialisation, matching the convention used by
162+
/// ParseReleaseVersions. The shape we depend on:
163+
/// [ { "tag_name": "v0.1.12", "draft": false,
164+
/// "assets": [ { "name": "...dll",
165+
/// "browser_download_url": "https://...",
166+
/// "digest": "sha256:abc..." } ] } ]
167+
/// </summary>
168+
public static (string Url, string Sha256Hex) ParseLatestDllAsset(string json)
169+
{
170+
if (string.IsNullOrEmpty(json)) return ("", "");
171+
172+
// Find the first release block — defined as the first
173+
// `"tag_name"` whose corresponding `"draft"` is false (or
174+
// absent). The block runs until the matching closing brace
175+
// at the top object level. We approximate that as "the
176+
// next `"tag_name"`" — assets always appear before the
177+
// next release object since GH returns assets inline.
178+
int idx = 0;
179+
while (idx < json.Length)
180+
{
181+
int tagAt = json.IndexOf("\"tag_name\"", idx, StringComparison.Ordinal);
182+
if (tagAt < 0) return ("", "");
183+
// Block end = the next "tag_name" (next release) or end of JSON.
184+
int nextTagAt = json.IndexOf("\"tag_name\"", tagAt + 1, StringComparison.Ordinal);
185+
int blockEnd = nextTagAt < 0 ? json.Length : nextTagAt;
186+
var block = json.Substring(tagAt, blockEnd - tagAt);
187+
idx = blockEnd;
188+
189+
// Skip drafts.
190+
if (LooksLikeDraft(block)) continue;
191+
192+
// Find the assets array and within it the first asset
193+
// whose `"name"` ends with ".dll" (we ship exactly one
194+
// DLL today; matching by suffix accommodates future
195+
// multi-asset releases).
196+
var (url, sha) = FindDllAsset(block);
197+
return (url, sha);
198+
}
199+
return ("", "");
200+
}
201+
202+
private static bool LooksLikeDraft(string block)
203+
{
204+
int draftAt = block.IndexOf("\"draft\"", StringComparison.Ordinal);
205+
if (draftAt < 0) return false;
206+
int colon = block.IndexOf(':', draftAt);
207+
if (colon < 0) return false;
208+
var after = block.Substring(colon + 1).TrimStart();
209+
return after.StartsWith("true", StringComparison.OrdinalIgnoreCase);
210+
}
211+
212+
private static (string Url, string Sha256Hex) FindDllAsset(string releaseBlock)
213+
{
214+
// Walk every `"name"` field in the block; for each one ending
215+
// ".dll", pluck the adjacent `browser_download_url` and
216+
// `digest`. GitHub orders the JSON keys consistently inside
217+
// each asset object so the adjacent search is stable, but we
218+
// search both directions defensively.
219+
int idx = 0;
220+
while (idx < releaseBlock.Length)
221+
{
222+
int nameAt = releaseBlock.IndexOf("\"name\"", idx, StringComparison.Ordinal);
223+
if (nameAt < 0) return ("", "");
224+
var nameVal = UploadClient.ExtractJsonString(releaseBlock.Substring(nameAt), "name") ?? "";
225+
idx = nameAt + "\"name\"".Length;
226+
227+
if (!nameVal.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) continue;
228+
229+
// Found a DLL asset. The browser_download_url + digest
230+
// for THIS asset are within the next ~1KB of JSON.
231+
int probeEnd = Math.Min(releaseBlock.Length, idx + 1500);
232+
var probe = releaseBlock.Substring(idx, probeEnd - idx);
233+
var url = UploadClient.ExtractJsonString(probe, "browser_download_url") ?? "";
234+
var digest = UploadClient.ExtractJsonString(probe, "digest") ?? "";
235+
236+
// Digest is shaped "sha256:HEX..." — strip the algo prefix.
237+
var sha = "";
238+
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
239+
{
240+
sha = digest.Substring("sha256:".Length).Trim().ToLowerInvariant();
241+
}
242+
return (url, sha);
243+
}
244+
return ("", "");
245+
}
246+
142247
/// <summary>
143248
/// Convert "v0.1.8" or "0.1.8" (with optional "-rc1" suffix) into
144249
/// a <see cref="Version"/>. Returns null on anything we can't make
@@ -256,7 +361,17 @@ public static async Task<UpdateCheckResult> CheckAsync(Version currentVersion, I
256361
var json = await fetcher.FetchAsync().ConfigureAwait(false);
257362
if (string.IsNullOrEmpty(json)) return result;
258363
var versions = ParseReleaseVersions(json);
259-
return Compute(currentVersion ?? new Version(0, 0, 0), versions);
364+
var status = Compute(currentVersion ?? new Version(0, 0, 0), versions);
365+
366+
// Latest-release asset info for the self-update flow.
367+
// Best-effort: missing/malformed asset metadata leaves
368+
// both strings empty and the installer falls back to
369+
// opening the browser instead of staging.
370+
var (url, sha) = ParseLatestDllAsset(json);
371+
status.LatestDllUrl = url;
372+
status.LatestDllSha256 = sha;
373+
374+
return status;
260375
}
261376
catch
262377
{

0 commit comments

Comments
 (0)