From 06411259815e351c87bda019a791397a6a5b7eb8 Mon Sep 17 00:00:00 2001 From: phall Date: Tue, 11 Aug 2026 05:55:53 -0400 Subject: [PATCH] feat(effects): open a URL in the user's default handler from update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `native-sdk.os.openUrl` was a webview bridge command and `openExternalURLIfAllowed:` a webview external-link policy decision, so a Zig core with no web content had no way to ask the OS to open a URL — the platform verb existed on every host (`PlatformServices.openExternalUrl`, NSWorkspace on macOS) with nothing app-facing wired to it. `fx.openUrl(url)` closes that. It follows `showNotification`, the channel's other fire-and-forget platform-service effect: validated on the loop thread, one synchronous platform call, no terminal Msg (the OS owns whether a handler launched, so a success Msg would over-promise), inert under fake execution and session replay. The URL is treated as hostile, because cores build them from terminal output, fetch bodies, and pastes. `validation.validateOpenUrl` refuses whole — never trimmed or coerced into something openable — an empty URL, one past `platform.max_external_url_bytes`, one carrying a NUL or any other control byte / whitespace / DEL, and any scheme outside the allowlist (`http`, `https`, `mailto`, matched case-insensitively so a shouty scheme is honored and a shouty `JavaScript:` still is not). `file:` and `javascript:` are refused by not appearing in it. Unlike `runtime.openExternalUrl`, the effect is not gated on the webview external-link policy: that policy governs links web content follows, while this call comes from the app's own `update`. A test observes it the way the notification effect is observed — bind the null platform's services and read `lastExternalUrl()`, which records the request without opening anything; a refused URL leaves it empty. Co-Authored-By: Claude Opus 5 (1M context) --- docs/src/app/docs/capabilities/page.mdx | 2 +- docs/src/app/docs/native-ui/page.mdx | 6 ++ src/runtime/effects.zig | 44 ++++++++- src/runtime/effects_open_url_tests.zig | 121 ++++++++++++++++++++++++ src/runtime/tests.zig | 1 + src/runtime/validation.zig | 37 ++++++++ 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 src/runtime/effects_open_url_tests.zig diff --git a/docs/src/app/docs/capabilities/page.mdx b/docs/src/app/docs/capabilities/page.mdx index e7039bfae..255659e36 100644 --- a/docs/src/app/docs/capabilities/page.mdx +++ b/docs/src/app/docs/capabilities/page.mdx @@ -19,7 +19,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w Open URL in system browser - Cmd.openExternalUrl(url) / runtime.openExternalUrl(url) + Cmd.openExternalUrl(url) / fx.openUrl(url) / runtime.openExternalUrl(url) native-sdk.os.openUrl network macOS, Linux, and Windows model cores and system WebView; macOS Chromium diff --git a/docs/src/app/docs/native-ui/page.mdx b/docs/src/app/docs/native-ui/page.mdx index e6428f2df..7d6932966 100644 --- a/docs/src/app/docs/native-ui/page.mdx +++ b/docs/src/app/docs/native-ui/page.mdx @@ -471,6 +471,12 @@ case "build_finished": +Handing a URL to the user's default handler is the same fire-and-forget shape: `fx.openUrl(url)` from a Zig `update_fx` arm asks the OS to open it — the browser for `http`/`https`, the mail client for `mailto` — through the platform verb the [bridge](/docs/bridge/builtin-commands) exposes to web content as `native-sdk.os.openUrl`. The URL is treated as hostile, because cores build them from terminal output, fetch bodies, and pastes: schemes are an allowlist (`http`, `https`, `mailto`, matched case-insensitively), and an empty URL, one past the 4 KiB bound, one carrying a NUL or any other control byte, or one naming an unvetted scheme — `file:` and `javascript:` included — is refused whole rather than trimmed into something openable. Unlike `runtime.openExternalUrl`, it is not gated on the webview external-link policy: that policy governs links *web content* follows, while this call comes from the app's own `update`. Fake execution and session replay never open anything. + +```zig +.open_docs => fx.openUrl("https://example.com/docs/start"), +``` + Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`. For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand. diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index ce420b003..eaf80f791 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -17,7 +17,11 @@ //! fire-and-forget exception: the OS owns delivery after the one //! loop-thread platform call, so there is no meaningful terminal Msg. //! Invalid requests and unavailable services fail closed; fake execution -//! and session replay never emit a real notification. Clipboard effects +//! and session replay never emit a real notification. Opening a URL in +//! the user's default handler (`openUrl`) is the same shape, with the +//! same reasoning, over an ALLOWLIST of schemes (http, https, mailto) — +//! app cores hand it untrusted bytes, so anything else is a whole +//! no-op. Clipboard effects //! (`writeClipboard`/`readClipboard`) keep //! the same shape over the platform pasteboard — the seam the //! runtime's cmd+C copy uses — executed synchronously on the loop @@ -8597,6 +8601,44 @@ pub fn Effects(comptime Msg: type) type { services.showNotification(options) catch {}; } + /// Ask the desktop host to open `url` in the user's default + /// handler — the browser for `http`/`https`, the mail client for + /// `mailto`. This is the `update`-side seam for the same + /// platform verb the webview bridge exposes as + /// `native-sdk.os.openUrl`; before it, a Zig core with no + /// webview had no way to reach it. Fire-and-forget for the same + /// reason `showNotification` is: the OS owns whether a handler + /// actually launched, so no success Msg would be truthful. + /// + /// The URL is treated as HOSTILE — apps hand this bytes that + /// came from terminal output, fetch bodies, and pastes — and is + /// validated against an ALLOWLIST of schemes + /// (`validation.open_url_schemes`: http, https, mailto) before + /// the platform sees it. Empty, over-bound + /// (`platform.max_external_url_bytes`), NUL- or control-byte- + /// bearing, and unrecognised-scheme URLs — `file:` and + /// `javascript:` among them — fail closed: a refused request is + /// a whole no-op, never a trimmed or coerced open. Nothing + /// reaches the platform, so a test's null platform records + /// nothing (`lastExternalUrl()`), which is how a rejection is + /// observed. + /// + /// Unlike the runtime's `openExternalUrl`, this is NOT gated on + /// the app's webview external-link policy: that policy governs + /// links WEB CONTENT follows, and this call comes from the app's + /// own `update`. + /// + /// The call runs synchronously on the loop thread, where the + /// platform launch services expect to be entered. Fake execution + /// and session replay suppress the platform call so tests stay + /// hermetic and a replay never reopens an external window. + pub fn openUrl(self: *Self, url: []const u8) void { + if (self.executor == .fake or self.replay) return; + validation.validateOpenUrl(url) catch return; + const services = self.services orelse return; + services.openExternalUrl(url) catch {}; + } + /// Put text on the system clipboard through the platform /// pasteboard — the same seam the runtime's cmd+C copy uses — /// and deliver exactly one terminal Msg with an explicit diff --git a/src/runtime/effects_open_url_tests.zig b/src/runtime/effects_open_url_tests.zig new file mode 100644 index 000000000..474054a23 --- /dev/null +++ b/src/runtime/effects_open_url_tests.zig @@ -0,0 +1,121 @@ +//! Open-URL effect coverage: `fx.openUrl` reaches the platform's +//! external-URL service on the loop thread, refuses hostile input +//! (unvetted schemes, control bytes, over-bound URLs) whole before the +//! platform sees any of it, and stays inert under fake execution and +//! session replay so tests/replays never launch a browser. + +const std = @import("std"); +const platform = @import("../platform/root.zig"); +const effects_mod = @import("effects.zig"); + +const Msg = enum { unused }; +const TestEffects = effects_mod.Effects(Msg); + +test "open-url effect hands an allowed URL to the platform service" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("https://example.com/docs/start"); + try std.testing.expectEqualStrings("https://example.com/docs/start", null_platform.lastExternalUrl()); + + // The other vetted schemes ride the same path. + fx.openUrl("http://example.com/plain"); + try std.testing.expectEqualStrings("http://example.com/plain", null_platform.lastExternalUrl()); + fx.openUrl("mailto:hello@example.com"); + try std.testing.expectEqualStrings("mailto:hello@example.com", null_platform.lastExternalUrl()); + + // Schemes are case-insensitive per RFC 3986, so the allowlist + // matches them that way rather than refusing a legitimate URL. + fx.openUrl("HTTPS://example.com/shouty"); + try std.testing.expectEqualStrings("HTTPS://example.com/shouty", null_platform.lastExternalUrl()); +} + +test "open-url effect refuses a javascript: URL" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("javascript:alert(1)"); + // Case games do not widen an allowlist. + fx.openUrl("JavaScript:alert(1)"); + + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} + +test "open-url effect refuses a file: URL" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("file:///etc/passwd"); + fx.openUrl("FILE:///etc/passwd"); + + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} + +test "open-url effect refuses an over-long URL" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + var long_url: [platform.max_external_url_bytes + 1]u8 = undefined; + const prefix = "https://example.com/"; + @memcpy(long_url[0..prefix.len], prefix); + @memset(long_url[prefix.len..], 'x'); + fx.openUrl(&long_url); + + // Rejected WHOLE: the bound never truncates a URL into a shorter + // one the OS would happily open. + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); + + // One byte under the bound still rides through, so the rejection + // above is the bound and not a broken path. + fx.openUrl(long_url[0..platform.max_external_url_bytes]); + try std.testing.expectEqual(platform.max_external_url_bytes, null_platform.lastExternalUrl().len); +} + +test "open-url effect refuses an embedded NUL and other control bytes" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("https://example.com/\x00javascript:alert(1)"); + fx.openUrl("https://example.com/ ok"); + fx.openUrl("https://example.com/\nfollow"); + fx.openUrl(""); + fx.openUrl("https://"); + fx.openUrl("ftp://example.com/file.zip"); + + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} + +test "open-url effect is inert without a service and during fake execution or replay" { + var unbound = TestEffects.init(std.testing.allocator); + defer unbound.deinit(); + unbound.openUrl("https://example.com/no-host"); + + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.executor = .fake; + fx.openUrl("https://example.com/fake"); + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); + + fx.armReplay(); + fx.openUrl("https://example.com/replay"); + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} diff --git a/src/runtime/tests.zig b/src/runtime/tests.zig index dbc7411aa..4d429d42f 100644 --- a/src/runtime/tests.zig +++ b/src/runtime/tests.zig @@ -21,6 +21,7 @@ test { _ = @import("effects_file_tests.zig"); _ = @import("effects_clipboard_tests.zig"); _ = @import("effects_notification_tests.zig"); + _ = @import("effects_open_url_tests.zig"); _ = @import("effects_audio_tests.zig"); _ = @import("effects_audio_capture_tests.zig"); _ = @import("effects_video_tests.zig"); diff --git a/src/runtime/validation.zig b/src/runtime/validation.zig index d5b7110e6..341431242 100644 --- a/src/runtime/validation.zig +++ b/src/runtime/validation.zig @@ -12,6 +12,43 @@ pub fn validateCommandName(name: []const u8) !void { } } +/// The schemes an app-facing open-URL request may name, matched +/// case-insensitively against the front of the URL (schemes are +/// case-insensitive per RFC 3986). An ALLOWLIST, not a denylist: +/// `file:` (hands the local disk to whatever handler claims it) and +/// `javascript:` (runs code inside the receiving handler) are refused by +/// simply not appearing, and so is every scheme nobody has vetted. A URL +/// assembled from untrusted bytes — terminal output, a fetch body, a +/// paste — can only reach the OS by matching an entry here. +pub const open_url_schemes = [_][]const u8{ "http://", "https://", "mailto:" }; + +/// Validate a URL the app asked the OS to open in the user's default +/// handler (`Effects.openUrl`). Bounded by +/// `platform.max_external_url_bytes`, the same bound the webview +/// bridge's `native-sdk.os.openUrl` enforces. Every failure rejects the +/// URL WHOLE — nothing here trims, escapes, or coerces a malformed URL +/// into a valid one. +pub fn validateOpenUrl(url: []const u8) !void { + if (url.len == 0) return error.InvalidOpenUrl; + if (url.len > platform.max_external_url_bytes) return error.OpenUrlTooLarge; + // A NUL truncates the URL at the C boundary every host crosses, and + // control bytes, whitespace, and DEL never appear in a well-formed + // URL — the whole class is refused rather than stripped, so a + // "https://ok\x00javascript:..." style splice cannot survive as its + // prefix. + for (url) |ch| { + if (ch <= 0x20 or ch == 0x7f) return error.InvalidOpenUrl; + } + for (open_url_schemes) |scheme| { + if (!std.ascii.startsWithIgnoreCase(url, scheme)) continue; + // A bare scheme names no target; only a scheme with something + // after it is worth handing to the OS. + if (url.len == scheme.len) return error.InvalidOpenUrl; + return; + } + return error.UnsupportedOpenUrlScheme; +} + pub fn validateRevealPath(path: []const u8) !void { if (path.len == 0) return error.InvalidRevealPath; if (path.len > platform.max_reveal_path_bytes) return error.RevealPathTooLarge;