From 44576a80a5fc28f384fe64fdb1155dc54f73594b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 17 Sep 2026 16:51:52 -0700 Subject: [PATCH 1/2] Fix native Windows checks against the GitHub release feed Use the project repository and accept additive API metadata, preserve owned results on allocation failure, constrain browser release destinations, and keep background checks non-modal. Run updater regressions in the native shell gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a98a529-f045-4c43-979c-00bae7640885 Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 4 + Tools/windows/Tests/WindowsShell.Tests.ps1 | 17 +++ Tools/windows/validation-matrix.md | 1 + graphcode-windows/README.md | 20 ++++ graphcode-windows/src/App.zig | 57 ++++++---- graphcode-windows/src/WindowsUpdates.zig | 107 ++++++++++++++++-- investigation/ui-parity-matrix.md | 4 +- 7 files changed, 176 insertions(+), 34 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 05890bfb..a5d79d89 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -184,6 +184,10 @@ try { if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { throw "RED: Windows shell validation does not execute the UI Automation live gate" } + $shellTests = Get-Content (Join-Path $PSScriptRoot "WindowsShell.Tests.ps1") -Raw + if ($shellTests -notmatch '(?s)Windows update feed executable tests.*?zig test src\\WindowsUpdates\.zig.*?-lwinhttp') { + throw "RED: Windows shell validation does not run the native updater tests" + } if ($runnerSource -notmatch '\$SkipWslRemoteE2E' -or $runnerSource -notmatch '"--skip-local-wsl"') { throw "RED: hosted validation cannot explicitly isolate unavailable local WSL fixtures" diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 9fe30370..e97f13db 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -35,6 +35,11 @@ $mainWindowSource = Get-Content (Join-Path $shellRoot "src\MainWindow.zig") -Raw $nativeFormsSource = Get-Content (Join-Path $shellRoot "src\NativeForms.zig") -Raw $inputSource = Get-Content (Join-Path $shellRoot "src\InputRouter.zig") -Raw $stubSource = Get-Content (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1") -Raw +Assert-Contract ($appSource -match + '(?s)pub fn checkForUpdates.*?requestUpdateCheck\(true\)' -and + $appSource -match 'if \(!envFlag\("GRAPHCODE_UIA_UPDATE_AVAILABLE"\)\) self\.requestUpdateCheck\(false\)' -and + $appSource -match 'shouldPresentOffer\(self\.update_user_initiated\)') ` + "explicit and background update checks must preserve their presentation intent" Assert-Contract ($appSource -match '(?s)app\.smoke_tick >= 16 and\s*app\.client\.connectionState\(\) == \.connected and\s*app\.currentProject\(\) != null and app\.model\.selected\(\) != null and\s*!app\.smoke_action_requested') ` "smoke graph command must wait for connection and selection instead of a single tick" @@ -317,6 +322,18 @@ Invoke-Native "Product Settings executable tests" { -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } +Invoke-Native "Windows update feed executable tests" { + $winghosttyRoot = $env:GRAPHCODE_WINGHOSTTY_ROOT + if (-not $winghosttyRoot) { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" + } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\WindowsUpdates.zig -target x86_64-windows-msvc -lc -lwinhttp "-I$include" + } finally { Pop-Location } +} Invoke-Native "Frame buffer executable tests" { Push-Location $shellRoot try { & $zig test src\FrameBuffer.zig } finally { Pop-Location } diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index b58d093d..38866315 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -18,6 +18,7 @@ The Windows port must have runnable commands before implementation fleets begin. | Deterministic release hardening fixtures | `pwsh Tools/windows/validate.ps1 -Task hardening` | | Signed catalog integrity and publisher policy | `pwsh Tools\windows\Tests\Packaging.Signing.Tests.ps1` | | Product/investigation provider pin consistency | `pwsh Tools\windows\Tests\ProviderPins.Tests.ps1` | +| Native release feed, URL handoff, and allocation safety | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes updater tests) | | Real-product packaging/install/upgrade/rollback | `pwsh Tools\windows\validate.ps1 -Task packaging` | | Shared Swift package | `swift test --package-path ` once extracted | | macOS app/daemon/CLI | `make test` | diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index e4244bed..80a8bf2b 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -56,6 +56,26 @@ bind the complete payload with a publisher-pinned Authenticode catalog; see `Tools\windows\PACKAGING.md`. There is not yet a published signed installer or an automatic install/relaunch path in the native updater. +## Update checks + +The native client checks `scgopi/GraphCode`'s GitHub releases API, accepting +additive release/asset metadata while retaining type checks on consumed fields. +The greatest eligible version from the fetched 30-release page is selected; +stable checks exclude prereleases and both channels exclude drafts. Existing +version ordering and cancellation/generation behavior are unchanged. Startup +and settings-refresh checks update status/sidebar offers without opening a modal; +only an explicit Check for Updates action or banner click opens the offer. + +An offer opens only the HTTPS release overview or a release-tag page in that +repository. It is a project-release notification, not proof of an installable +Windows artifact: the dialog explicitly notes that assets may target other +platforms and that Windows installation/relaunch is not implemented. +No installer is downloaded or executed. + +`Tools\windows\Tests\WindowsShell.Tests.ps1` runs the native updater contracts, +including realistic GitHub response fields, URL handoff validation, stable/beta +selection, malformed consumed fields, and allocation-failure cleanup. + ## Tray lifecycle coverage The shell registers a version-4 notification icon with a stable `HWND`/icon ID diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 901a54bd..540d7951 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -232,6 +232,7 @@ pub const App = struct { update_cancel: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), update_generation: u64 = 0, update_pending: bool = false, + update_user_initiated: bool = false, update_version: []u8 = &.{}, update_release_url: []u8 = &.{}, smoke_restart_index: ?usize = null, @@ -411,7 +412,7 @@ pub const App = struct { if (self.workspace) |workspace| try workspace.startInputWorker(); } self.layoutWorkspace(); - if (!envFlag("GRAPHCODE_UIA_UPDATE_AVAILABLE")) self.requestUpdateCheck(); + if (!envFlag("GRAPHCODE_UIA_UPDATE_AVAILABLE")) self.requestUpdateCheck(false); if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_REQUIRE_DAEMON")) |value| { defer self.allocator.free(value); self.require_smoke_contract = std.mem.eql(u8, value, "1"); @@ -1138,7 +1139,7 @@ pub const App = struct { self.update_state = WindowsUpdates.CheckState.configure(draft.beta); self.update_lock.unlock(); self.setStatus("Checking for updates…"); - self.requestUpdateCheck(); + self.requestUpdateCheck(false); _ = c.InvalidateRect(self.window.hwnd, null, 0); } @@ -1216,13 +1217,14 @@ pub const App = struct { pub fn checkForUpdates(self: *App) void { self.setStatus("Checking for updates..."); - self.requestUpdateCheck(); + self.requestUpdateCheck(true); self.updateNativeChrome(); } - fn requestUpdateCheck(self: *App) void { + fn requestUpdateCheck(self: *App, user_initiated: bool) void { self.update_lock.lock(); self.update_generation += 1; + self.update_user_initiated = user_initiated; self.update_pending = true; if (self.update_thread != null) { self.update_cancel.store(true, .release); @@ -1263,7 +1265,7 @@ pub const App = struct { }; defer self.allocator.free(version); var client = WindowsUpdates.CheckClient{ .allocator = self.allocator }; - const result = client.checkWithCancel(beta, version, &self.update_cancel) catch { + var result = client.checkWithCancel(beta, version, &self.update_cancel) catch { self.update_lock.lock(); if (generation == self.update_generation and !self.update_cancel.load(.acquire)) self.update_state = .{ .channel = if (beta) .beta else .stable, .state = .failed }; @@ -1271,22 +1273,16 @@ pub const App = struct { self.update_lock.unlock(); return; }; - defer { - var owned = result; - owned.deinit(self.allocator); - } - const version_copy = if (result.version) |value| self.allocator.dupe(u8, value) catch null else null; - const url_copy = if (result.release_url) |value| self.allocator.dupe(u8, value) catch null else null; + defer result.deinit(self.allocator); self.update_lock.lock(); if (generation == self.update_generation and !self.update_cancel.load(.acquire)) { self.update_state = .{ .channel = result.channel, .state = result.state }; if (self.update_version.len != 0) self.allocator.free(self.update_version); if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); - self.update_version = version_copy orelse &.{}; - self.update_release_url = url_copy orelse &.{}; - } else { - if (version_copy) |value| self.allocator.free(value); - if (url_copy) |value| self.allocator.free(value); + self.update_version = result.version orelse &.{}; + self.update_release_url = result.release_url orelse &.{}; + result.version = null; + result.release_url = null; } self.update_done = true; self.update_lock.unlock(); @@ -1304,7 +1300,7 @@ pub const App = struct { const pending = self.update_pending; self.update_pending = false; const label = self.update_state.label(); - const available = self.update_state.state == .available; + const present_offer = self.update_state.shouldPresentOffer(self.update_user_initiated); const version = self.update_version; const release_url = self.update_release_url; self.update_lock.unlock(); @@ -1312,19 +1308,29 @@ pub const App = struct { self.launchUpdateCheck(); } else { self.setStatus(label); - if (available) self.showAvailableUpdate(version, release_url); + if (present_offer) self.showAvailableUpdate(version, release_url); } } } fn showAvailableUpdate(self: *App, version: []const u8, release_url: []const u8) void { + const url = WindowsUpdates.releasePageUrl(release_url) catch { + self.setStatus("Update release URL is not a trusted GraphCode release page"); + return; + }; const message = std.fmt.allocPrint( self.allocator, - "GraphCode {s} is available.\n\nOpen the verified GitHub release page to review release notes and download the Windows package?", + "GraphCode {s} is available.\n\nOpen the verified GitHub release page to review release notes?\n\nIn-app Windows installation and relaunch are not available. Release assets may target other platforms.", .{if (version.len == 0) "update" else version}, - ) catch return; + ) catch { + self.setStatus("Unable to prepare the update offer"); + return; + }; defer self.allocator.free(message); - const message_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, message) catch return; + const message_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, message) catch { + self.setStatus("Unable to encode the update offer"); + return; + }; defer self.allocator.free(message_wide); if (c.MessageBoxW( self.window.hwnd, @@ -1332,7 +1338,6 @@ pub const App = struct { std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Update Available").ptr, c.MB_ICONINFORMATION | c.MB_YESNO | c.MB_DEFBUTTON1, ) != c.IDYES) return; - const url = if (release_url.len != 0) release_url else "https://github.com/GraphCode/GraphCode/releases"; const url_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, url) catch { self.setStatus("Unable to encode the release URL"); return; @@ -1358,7 +1363,11 @@ pub const App = struct { defer if (version) |value| self.allocator.free(value); defer if (release_url) |value| self.allocator.free(value); if (!available) return; - self.showAvailableUpdate(version orelse "", release_url orelse ""); + if (version == null or release_url == null) { + self.setStatus("Unable to prepare the update offer"); + return; + } + self.showAvailableUpdate(version.?, release_url.?); } fn addRemoteRepository(self: *App) void { @@ -2116,7 +2125,7 @@ pub const App = struct { if (self.update_version.len != 0) self.allocator.free(self.update_version); if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); self.update_version = self.allocator.dupe(u8, "9.9.9-test") catch &.{}; - self.update_release_url = self.allocator.dupe(u8, "https://github.com/GraphCode/GraphCode/releases/tag/v9.9.9-test") catch &.{}; + self.update_release_url = self.allocator.dupe(u8, WindowsUpdates.releases_page_url ++ "/tag/v9.9.9-test") catch &.{}; self.update_lock.unlock(); } if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_INGRESS_ERROR")) |message| { diff --git a/graphcode-windows/src/WindowsUpdates.zig b/graphcode-windows/src/WindowsUpdates.zig index b907eee0..3f4ef56a 100644 --- a/graphcode-windows/src/WindowsUpdates.zig +++ b/graphcode-windows/src/WindowsUpdates.zig @@ -3,6 +3,16 @@ const c = @import("Win32.zig").c; pub const Channel = enum { stable, beta }; pub const State = enum { disabled, available, up_to_date, failed }; +pub const releases_page_url = "https://github.com/scgopi/GraphCode/releases"; + +pub fn releasePageUrl(value: []const u8) ![]const u8 { + if (value.len == 0) return releases_page_url; + for (value) |byte| if (byte <= 0x20 or byte == 0x7f) return error.InvalidReleaseUrl; + const tag_prefix = releases_page_url ++ "/tag/"; + if (std.mem.eql(u8, value, releases_page_url) or + (std.mem.startsWith(u8, value, tag_prefix) and value.len > tag_prefix.len)) return value; + return error.InvalidReleaseUrl; +} pub fn acceptsResult(current_generation: u64, result_generation: u64, cancelled: bool) bool { return !cancelled and current_generation == result_generation; @@ -31,6 +41,10 @@ pub const CheckState = struct { return .{ .channel = if (beta_enabled) .beta else .stable, .state = .up_to_date }; } + pub fn shouldPresentOffer(self: CheckState, user_initiated: bool) bool { + return user_initiated and self.state == .available; + } + pub fn label(self: CheckState) []const u8 { return switch (self.state) { .disabled => "Updates disabled", @@ -43,7 +57,7 @@ pub const CheckState = struct { pub const CheckClient = struct { allocator: std.mem.Allocator, - feed_url: []const u8 = "https://api.github.com/repos/GraphCode/GraphCode/releases", + feed_url: []const u8 = "https://api.github.com/repos/scgopi/GraphCode/releases?per_page=30", pub fn check(self: CheckClient, beta_enabled: bool, current_version: []const u8) !CheckResult { var cancelled = std.atomic.Value(bool).init(false); @@ -137,14 +151,18 @@ pub fn currentVersionFromMetadata(allocator: std.mem.Allocator, metadata: ?[]con } fn parseFeed(allocator: std.mem.Allocator, body: []const u8, channel: Channel, current_version: []const u8) !CheckResult { - var parsed = try std.json.parseFromSlice([]const Release, allocator, body, .{}); + var parsed = try std.json.parseFromSlice([]const Release, allocator, body, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); var installed = try SemVer.parse(allocator, current_version); defer installed.deinit(allocator); var greatest: ?struct { release: Release, version: SemVer } = null; + defer if (greatest) |selected| selected.version.deinit(allocator); for (parsed.value) |release| { if (release.draft or (channel == .stable and release.prerelease)) continue; - const candidate = SemVer.parse(allocator, release.tag_name) catch continue; + const candidate = SemVer.parse(allocator, release.tag_name) catch |err| switch (err) { + error.InvalidVersion => continue, + else => return err, + }; if (greatest == null or candidate.compare(greatest.?.version) == .greater) { if (greatest) |old| old.version.deinit(allocator); greatest = .{ .release = release, .version = candidate }; @@ -153,13 +171,14 @@ fn parseFeed(allocator: std.mem.Allocator, body: []const u8, channel: Channel, c } } if (greatest) |selected| { - defer selected.version.deinit(allocator); - return .{ + var result = CheckResult{ .channel = channel, .state = if (selected.version.compare(installed) == .greater) .available else .up_to_date, .version = try allocator.dupe(u8, selected.release.tag_name), - .release_url = if (selected.release.html_url) |url| try allocator.dupe(u8, url) else null, }; + errdefer result.deinit(allocator); + if (selected.release.html_url) |url| result.release_url = try allocator.dupe(u8, try releasePageUrl(url)); + return result; } return .{ .channel = channel, .state = .failed, .message = try allocator.dupe(u8, "No release found for selected channel") }; } @@ -267,21 +286,93 @@ const Release = struct { draft: bool = false, }; +test "default update feed uses the GraphCode release repository" { + const client = CheckClient{ .allocator = std.testing.allocator }; + try std.testing.expectEqualStrings("https://api.github.com/repos/scgopi/GraphCode/releases?per_page=30", client.feed_url); +} + +test "background update checks do not interrupt the workspace with a modal offer" { + const available = CheckState{ .state = .available }; + try std.testing.expect(!available.shouldPresentOffer(false)); + try std.testing.expect(available.shouldPresentOffer(true)); + for ([_]State{ .disabled, .up_to_date, .failed }) |state| { + const check = CheckState{ .state = state }; + try std.testing.expect(!check.shouldPresentOffer(true)); + try std.testing.expect(!check.shouldPresentOffer(false)); + } +} + +test "GitHub release response accepts additive metadata without losing channel filtering" { + const releases = + \\[ + \\ {"id":100,"tag_name":"v2.0.0","html_url":"https://github.com/scgopi/GraphCode/releases/tag/v2.0.0", + \\ "prerelease":false,"draft":false,"name":"GraphCode 2.0.0","body":"Release notes", + \\ "author":{"login":"fixture","id":1},"published_at":"2026-09-17T00:00:00Z", + \\ "assets":[{"name":"graphcode-macos-arm64.dmg","size":123,"digest":"sha256:fixture"}]}, + \\ {"id":101,"tag_name":"v3.0.0-beta1","prerelease":true,"draft":false,"assets":[]}, + \\ {"id":102,"tag_name":"v9.0.0","prerelease":false,"draft":true,"immutable":false} + \\] + ; + var result = try parseFeed(std.testing.allocator, releases, .stable, "1.0.0"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, result.state); + try std.testing.expectEqualStrings("v2.0.0", result.version.?); + try std.testing.expectEqualStrings("https://github.com/scgopi/GraphCode/releases/tag/v2.0.0", result.release_url.?); +} + +test "known update feed fields remain type checked" { + try std.testing.expectError(error.UnexpectedToken, parseFeed(std.testing.allocator, + \\[{"tag_name":"v2.0.0","prerelease":"false"}] + , .stable, "1.0.0")); +} + +test "update feed allocation failures propagate without leaking selected releases" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + const releases = + \\[{"tag_name":"v1.1.0"},{"tag_name":"not-a-version"},{"tag_name":"v2.0.0","html_url":"https://github.com/scgopi/GraphCode/releases/tag/v2.0.0"}] + ; + var result = try parseFeed(allocator, releases, .stable, "1.0.0"); + defer result.deinit(allocator); + try std.testing.expectEqualStrings("v2.0.0", result.version.?); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + test "real update feed result follows stable and beta channels" { const stable = - \\[{"tag_name":"v2.0.0","html_url":"https://example.test/v2","prerelease":false,"draft":false},{"tag_name":"v3.0.0-beta","prerelease":true,"draft":false}] + \\[{"tag_name":"v2.0.0","html_url":"https://github.com/scgopi/GraphCode/releases/tag/v2.0.0","prerelease":false,"draft":false},{"tag_name":"v3.0.0-beta","prerelease":true,"draft":false}] ; var stable_result = try parseFeed(std.testing.allocator, stable, .stable, "v1.0.0"); defer stable_result.deinit(std.testing.allocator); try std.testing.expectEqual(State.available, stable_result.state); try std.testing.expectEqual(Channel.stable, stable_result.channel); - try std.testing.expectEqualStrings("https://example.test/v2", stable_result.release_url.?); + try std.testing.expectEqualStrings("https://github.com/scgopi/GraphCode/releases/tag/v2.0.0", stable_result.release_url.?); var beta_result = try parseFeed(std.testing.allocator, stable, .beta, "v3.0.0-beta"); defer beta_result.deinit(std.testing.allocator); try std.testing.expectEqual(State.up_to_date, beta_result.state); try std.testing.expectEqualStrings("v3.0.0-beta", beta_result.version.?); } +test "release page handoff stays within the GraphCode release repository" { + try std.testing.expectEqualStrings(releases_page_url, try releasePageUrl("")); + try std.testing.expectEqualStrings(releases_page_url, try releasePageUrl(releases_page_url)); + const tag = releases_page_url ++ "/tag/v2.0.0-beta1"; + try std.testing.expectEqualStrings(tag, try releasePageUrl(tag)); + for ([_][]const u8{ + "file:///C:/untrusted.exe", + "http://github.com/scgopi/GraphCode/releases/tag/v2", + "https://github.com/GraphCode/GraphCode/releases", + "https://github.com.evil.test/scgopi/GraphCode/releases/tag/v2", + releases_page_url ++ "/tag/", + releases_page_url ++ "/tag/v2\r\n", + }) |invalid| try std.testing.expectError(error.InvalidReleaseUrl, releasePageUrl(invalid)); + try std.testing.expectError(error.InvalidReleaseUrl, parseFeed(std.testing.allocator, + \\[{"tag_name":"v2.0.0","html_url":"file:///C:/untrusted.exe"}] + , .stable, "1.0.0")); +} + test "release tags and installed versions compare semantically" { const releases = \\[{"tag_name":"V1.2.3","prerelease":false,"draft":false}] diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index c58c1622..d0edef69 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -133,8 +133,8 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Available update alert | Install, Release Notes, Later | A successful update check now retains the offered version and authoritative release URL, presents a native available-update alert, and can open the verified GitHub release page for notes/download. Direct in-app Install and separately labeled Later remain incomplete | Partial | -| Install progress | In-window progress indicator | Blocked on a published, signed Windows installer and updater integration. ZIP packaging now supports a SHA-256 Authenticode catalog plus independently supplied publisher thumbprint, with native catalog tamper coverage and simulated OS trust decisions; this is not production signing or installer evidence. The last recorded release-asset check (2026-08-17) found only macOS DMGs. No Windows download/install path is enabled | Blocked | +| Available update alert | Install, Release Notes, Later | The native client now reads the real `scgopi/GraphCode` API response shape, retains the offered project version, and only opens that repository's HTTPS release pages. Native fixtures cover API metadata, channel filtering, URL validation, and allocation failure. The alert explicitly distinguishes project release notes from a Windows package and says installation/relaunch are unavailable. Direct in-app Install and separately labeled Later remain incomplete | Partial | +| Install progress | In-window progress indicator | Blocked on a published, signed Windows installer and updater integration. ZIP packaging supports a SHA-256 Authenticode catalog plus independently supplied publisher thumbprint, with native catalog tamper coverage and simulated OS trust decisions; this is not production signing or installer evidence. The latest stable and beta release-asset check (2026-09-17) found only macOS DMGs. No Windows download/install path is enabled | Blocked | | Relaunch prompt | Relaunch Now/Later and session continuity explanation | Blocked with installation because there is no published Windows artifact to stage or relaunch into. The native tray lifecycle and zmx-backed sessions already preserve daemon/terminal continuity, but the updater cannot truthfully offer Relaunch Now until a signed Windows package exists | Blocked | | Install failure | Download in Browser/Cancel with reason | The Windows flow deliberately hands off to the verified browser download and reports browser-launch failure, but it does not yet attempt an in-app install first | Partial | | Loop rename | Title field, Return submits, explanatory text | The dedicated single-title modal explains where the title appears, prepopulates the current value, trims and validates submission, and re-resolves the stable loop ID after the modal. The populated UIA gate edits the native field and verifies Return submits and closes the dialog | Validated | From 1ac61a391e0df06700826e1789336d7d932ad588 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 17 Sep 2026 17:31:39 -0700 Subject: [PATCH 2/2] Reject normalized path escapes in Windows release handoff Allow only a single safe version-tag segment so encoded dot paths, backslashes, and added URL components cannot navigate away from the release route. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a98a529-f045-4c43-979c-00bae7640885 Signed-off-by: Colin Neilens --- graphcode-windows/README.md | 7 ++++--- graphcode-windows/src/WindowsUpdates.zig | 22 +++++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 80a8bf2b..4973f687 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -66,9 +66,10 @@ version ordering and cancellation/generation behavior are unchanged. Startup and settings-refresh checks update status/sidebar offers without opening a modal; only an explicit Check for Updates action or banner click opens the offer. -An offer opens only the HTTPS release overview or a release-tag page in that -repository. It is a project-release notification, not proof of an installable -Windows artifact: the dialog explicitly notes that assets may target other +An offer opens only the HTTPS release overview or a single-segment version-tag +page in that repository; encoded paths, dot segments, query/fragment additions, +and backslash separators are rejected. It is a project-release notification, +not proof of an installable Windows artifact: the dialog notes that assets may target other platforms and that Windows installation/relaunch is not implemented. No installer is downloaded or executed. diff --git a/graphcode-windows/src/WindowsUpdates.zig b/graphcode-windows/src/WindowsUpdates.zig index 3f4ef56a..364fbe10 100644 --- a/graphcode-windows/src/WindowsUpdates.zig +++ b/graphcode-windows/src/WindowsUpdates.zig @@ -8,10 +8,17 @@ pub const releases_page_url = "https://github.com/scgopi/GraphCode/releases"; pub fn releasePageUrl(value: []const u8) ![]const u8 { if (value.len == 0) return releases_page_url; for (value) |byte| if (byte <= 0x20 or byte == 0x7f) return error.InvalidReleaseUrl; + if (std.mem.eql(u8, value, releases_page_url)) return value; const tag_prefix = releases_page_url ++ "/tag/"; - if (std.mem.eql(u8, value, releases_page_url) or - (std.mem.startsWith(u8, value, tag_prefix) and value.len > tag_prefix.len)) return value; - return error.InvalidReleaseUrl; + if (!std.mem.startsWith(u8, value, tag_prefix)) return error.InvalidReleaseUrl; + const tag = value[tag_prefix.len..]; + if (tag.len == 0 or std.mem.eql(u8, tag, ".") or std.mem.eql(u8, tag, "..")) + return error.InvalidReleaseUrl; + for (tag) |byte| { + if (!std.ascii.isAlphanumeric(byte) and byte != '.' and byte != '-' and byte != '_' and byte != '+') + return error.InvalidReleaseUrl; + } + return value; } pub fn acceptsResult(current_generation: u64, result_generation: u64, cancelled: bool) bool { @@ -360,6 +367,8 @@ test "release page handoff stays within the GraphCode release repository" { try std.testing.expectEqualStrings(releases_page_url, try releasePageUrl(releases_page_url)); const tag = releases_page_url ++ "/tag/v2.0.0-beta1"; try std.testing.expectEqualStrings(tag, try releasePageUrl(tag)); + const build_tag = releases_page_url ++ "/tag/v2.0.0+build.1"; + try std.testing.expectEqualStrings(build_tag, try releasePageUrl(build_tag)); for ([_][]const u8{ "file:///C:/untrusted.exe", "http://github.com/scgopi/GraphCode/releases/tag/v2", @@ -367,6 +376,13 @@ test "release page handoff stays within the GraphCode release repository" { "https://github.com.evil.test/scgopi/GraphCode/releases/tag/v2", releases_page_url ++ "/tag/", releases_page_url ++ "/tag/v2\r\n", + releases_page_url ++ "/tag/../../../../other/project", + releases_page_url ++ "/tag/%2e%2e", + releases_page_url ++ "/tag/..\\..\\other", + releases_page_url ++ "/tag/.", + releases_page_url ++ "/tag/..", + releases_page_url ++ "/tag/v2?other", + releases_page_url ++ "/tag/v2#other", }) |invalid| try std.testing.expectError(error.InvalidReleaseUrl, releasePageUrl(invalid)); try std.testing.expectError(error.InvalidReleaseUrl, parseFeed(std.testing.allocator, \\[{"tag_name":"v2.0.0","html_url":"file:///C:/untrusted.exe"}]