Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/app/docs/capabilities/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
<tbody>
<tr>
<td>Open URL in system browser</td>
<td><code>Cmd.openExternalUrl(url)</code> / <code>runtime.openExternalUrl(url)</code></td>
<td><code>Cmd.openExternalUrl(url)</code> / <code>fx.openUrl(url)</code> / <code>runtime.openExternalUrl(url)</code></td>
<td><code>native-sdk.os.openUrl</code></td>
<td><code>network</code></td>
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
Expand Down
6 changes: 6 additions & 0 deletions docs/src/app/docs/native-ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,12 @@ case "build_finished":

</CodeToggle>

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.
Expand Down
44 changes: 43 additions & 1 deletion src/runtime/effects.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions src/runtime/effects_open_url_tests.zig
Original file line number Diff line number Diff line change
@@ -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());
}
1 change: 1 addition & 0 deletions src/runtime/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
37 changes: 37 additions & 0 deletions src/runtime/validation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down