feat(storage): harden file effects (#339)

* feat(storage): harden file effects

- Add bounded streaming reads, atomic write sinks, stat, and append effects.
- Gate external paths with symlink-safe filesystem permission checks.
- Preserve deterministic record/replay through content-addressed stream blobs.

* fix(storage): harden file effect lifecycles

* fix(storage): harden stream replay and key ownership

* fix(storage): keep stream sink on rejected chunks

* fix(storage): address file stream review findings
This commit is contained in:
Chris Tate
2026-08-13 14:10:10 -05:00
committed by GitHub
parent f228e861d1
commit baef0d96d3
61 changed files with 3414 additions and 108 deletions
+1
View File
@@ -42,6 +42,7 @@ docs/tsconfig.tsbuildinfo
# Dev-tool dependency trees (installed per package, never committed)
packages/core/node_modules/
.pnpm-store/
# The CLI-materialized editor copy of @native-sdk/core inside the TS example
# apps (node_modules is editor surface, never source — the same entry the
+27
View File
@@ -203,6 +203,31 @@ pub fn build(b: *std.Build) void {
desktop_mod.link_libc = true;
const desktop_tests = testArtifact(b, desktop_mod);
const desktop_test_shards = desktopTestShardArtifacts(b, desktop_mod);
// Tier-5 crash battery: a child uses the public streamed sink, signals
// after a chunk is durable only in the atomic temporary, then the parent
// kills it and verifies the destination still names the old generation.
const file_crash_helper_mod = module(b, target, optimize, "src/runtime/file_effect_crash_helper.zig");
file_crash_helper_mod.addImport("native_sdk", desktop_mod);
const file_crash_helper = b.addExecutable(.{ .name = "file-effect-crash-helper", .root_module = file_crash_helper_mod });
const file_crash_tests_mod = module(b, target, optimize, "src/runtime/file_effect_crash_tests.zig");
file_crash_tests_mod.addImport("native_sdk", desktop_mod);
const file_crash_options = b.addOptions();
file_crash_options.addOptionPath("helper_executable", file_crash_helper.getEmittedBin());
file_crash_tests_mod.addOptions("file_crash_options", file_crash_options);
const file_crash_tests = testArtifact(b, file_crash_tests_mod);
const file_crash_run = b.addRunArtifact(file_crash_tests);
const file_crash_step = b.step("test-file-effect-crash", "Kill a streamed writer before close and verify atomic destination visibility");
file_crash_step.dependOn(&file_crash_run.step);
const tier5_tests = filteredTestArtifact(b, desktop_mod, "storage-tier-5-tests", &.{
"runtime.effects_file_tests.test",
"runtime.file_access.test",
"runtime.session_record.test.recorder moves streamed file chunks",
"runtime.session_tests.test.a multi-megabyte file stream",
});
const tier5_run = b.addRunArtifact(tier5_tests);
const tier5_step = b.step("test-storage-tier-5", "Run file streaming, bounds, replay/blob, gating, and crash-atomicity batteries");
tier5_step.dependOn(&tier5_run.step);
tier5_step.dependOn(&file_crash_run.step);
// SQLite is capability-shed from ordinary app artifacts. Its focused
// engine/store suite gets a dedicated module that explicitly compiles the
@@ -564,6 +589,7 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(app_runner_assets_tests).step);
test_step.dependOn(&b.addRunArtifact(canvas_tests).step);
test_step.dependOn(&b.addRunArtifact(record_store_tests).step);
test_step.dependOn(&file_crash_run.step);
for (desktop_test_shards) |shard_tests| {
test_step.dependOn(&b.addRunArtifact(shard_tests).step);
}
@@ -3665,6 +3691,7 @@ fn externalCoreFixtureModule(
if (spec.store_capability) check.addArgs(&.{ "--capability", "store" });
if (spec.relational_capability) check.addArgs(&.{ "--capability", "sqlite" });
if (spec.credentials_capability) check.addArgs(&.{ "--capability", "credentials", "--permission", "credentials" });
if (std.mem.eql(u8, spec.entry, "tests/ts-core/fixture.ts")) check.addArgs(&.{ "--permission", "filesystem" });
tsCoreAddDirInputs(b, check, "packages/core/sdk");
tsCoreAddDirInputs(b, check, std.fs.path.dirname(spec.entry) orelse ".");
const frontend_sources = [_][]const u8{
+6
View File
@@ -1052,6 +1052,8 @@ pub const MobileLibOptions = struct {
credentials_capability: bool = false,
/// Grant those effects access to the registered OS credential service.
credentials_permission: bool = false,
/// Grant raw file effects access outside the OS-owned app data root.
filesystem_permission: bool = false,
/// Stable app identity used as the Keychain/Keystore service namespace.
credentials_service: []const u8 = "dev.native_sdk.app",
};
@@ -1096,6 +1098,7 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std
mobile_options.addOption(bool, "relational_capability", options.relational_capability);
mobile_options.addOption(bool, "credentials_capability", options.credentials_capability);
mobile_options.addOption(bool, "credentials_permission", options.credentials_permission);
mobile_options.addOption(bool, "filesystem_permission", options.filesystem_permission);
mobile_options.addOption([]const u8, "credentials_service", options.credentials_service);
exports_mod.addImport("mobile_build_options", mobile_options.createModule());
const migration_path = options.relational_migrations orelse dep.path("src/app_runner/no_migrations.zig");
@@ -1246,6 +1249,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
.relational_migrations = relational_migrations,
.credentials_capability = app_config.credentials_capability,
.credentials_permission = app_config.credentials_permission,
.filesystem_permission = app_config.filesystem_permission,
.credentials_service = app_config.app_id,
});
}
@@ -2187,6 +2191,7 @@ const AppManifestBuildConfig = struct {
relational_capability: bool = false,
credentials_capability: bool = false,
credentials_permission: bool = false,
filesystem_permission: bool = false,
sqlite_capability: bool = false,
/// The first web declaration found (for teaching messages), or null
/// when app.zon declares no web use. `web_engine = "system"` alone is
@@ -2280,6 +2285,7 @@ fn appManifestBuildConfig(b: *std.Build, app_root: []const u8) AppManifestBuildC
.relational_capability = hasManifestCapability(raw.capabilities, "sqlite"),
.credentials_capability = hasManifestCapability(raw.capabilities, "credentials"),
.credentials_permission = hasManifestPermission(raw.permissions, "credentials"),
.filesystem_permission = hasManifestPermission(raw.permissions, "filesystem"),
.sqlite_capability = hasManifestCapability(raw.capabilities, "store") or hasManifestCapability(raw.capabilities, "sqlite"),
.web_declaration = web_layer_contract.manifestDeclaration(raw),
};
+1
View File
@@ -4,3 +4,4 @@ next-env.d.ts
.next-gate/
.next-agent/
.next-check/
.next-final/
+6
View File
@@ -261,6 +261,12 @@ Edits refuse rather than guess: a widget authored in Zig, a file that changed on
`zig build test-writeback-smoke` (macOS) drives the whole loop against the kanban example: query provenance, flip the button label through the verb, assert the repaint, verify the byte-exact diff, and flip it back.
## Session recording and blob growth
`NATIVE_SDK_SESSION_RECORD=/path/session.journal` records platform events and effect results; `NATIVE_SDK_SESSION_REPLAY` replays that journal without touching the original network, process, database, or file. Large effect payloads—including every streamed file-read chunk—live content-addressed under the sibling `blobs/` directory, and the journal stores each hash and length. Keep the journal and `blobs/` together when copying a recording.
The blob store deduplicates identical chunks but is intentionally unbounded today: there is no automatic size quota or garbage collector. Long recordings and repeated large imports can therefore grow the directory substantially. Treat a recording as one disposable artifact, monitor its directory size in long automation runs, and delete the journal plus its sibling `blobs/` directory when it is no longer needed. A future GC must reason over every retained journal before removing an unreferenced content address; Tier 5 does not guess at that retention policy.
## Custom directory
Pass a custom path to `automation.Server.init()`:
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("files");
export default function FilesLayout({ children }: { children: React.ReactNode }) {
return children;
}
+65
View File
@@ -0,0 +1,65 @@
# Files and streaming
Raw file effects are the escape hatch for files the user owns: imports, exports, recordings, images, CSV, and other blobs. App state belongs in [Model Persistence](/docs/persistence), the [Record Store](/docs/record-store), or [Relational SQLite](/docs/sqlite); secrets belong in credentials.
Every operation is still an effect. `update` returns a `Cmd`, the host performs I/O after the model commits, and results return as later `Msg` values. Session recording journals those results; streamed chunks live in the content-addressed `blobs/` directory beside the journal so replay never opens the original file.
The blob directory is deduplicated but currently has no quota or automatic GC; see [Automation: Session recording and blob growth](/docs/automation#session-recording-and-blob-growth).
## Small whole-file operations
`Cmd.readFile` and `Cmd.writeFile` remain convenient for payloads up to 1 MiB. An over-bound write is rejected. An over-bound read returns `truncated` rather than passing cut bytes as a successful file. `Cmd.appendFile` appends one payload up to 1 MiB, and `Cmd.statFile` reports `{ exists, size, mtimeMs }` without reading the content.
```ts
return [model, Cmd.statFile(path, {
ok: "file_stat",
err: "file_failed",
})];
```
File outcomes are closed and machine-readable: `ok`, `not_found`, `io_failed`, `truncated`, `rejected`, `cancelled`, `sink_missing`, `out_of_order`, and `disk_full`.
## Streaming reads
`Cmd.readFileStream` delivers zero or more 256-KiB chunks, followed by exactly one `done` message carrying the total byte count, or one `err` message.
Read-stream keys follow ordinary file semantics: reissuing a live key silently replaces the old read, and `Cmd.cancel(key)` silently drops it. Stale chunks from the retired generation cannot reach the replacement route.
```ts
return [model, Cmd.readFileStream(path, {
key: "import",
chunk: "import_chunk",
done: "import_done",
err: "import_failed",
})];
```
Streaming has its own four-slot budget and no total-size ceiling. Each individual chunk remains bounded, so one large import cannot consume the sixteen general spawn/fetch/whole-file slots.
## Atomic streaming writes
Open a sink, send one acknowledged chunk at a time, then close it. Close syncs the temporary file and atomically replaces the destination; before a successful close, the old destination stays visible and teardown removes the temporary file.
```ts
Cmd.writeFileStream("export", path, { ok: "sink_open", err: "export_failed" });
Cmd.writeFileChunk("export", bytes, { ok: "chunk_written", err: "export_failed" });
Cmd.writeFileClose("export", { ok: "export_done", err: "export_failed" });
```
A duplicate live sink rejects. A chunk or close issued before the previous acknowledgment returns `out_of_order`; a chunk or close with no open sink returns `sink_missing`. Chunks may be at most 1 MiB.
Write sinks use the loud stream discipline: `Cmd.cancel(key)` ends the sink through `err: cancelled` and removes its temporary file. A half-written export is never silently replaced or installed.
## Filesystem permission
Raw paths inside this app's resolved `data`, `config`, `cache`, `state`, `logs`, and `temp` directories need no permission. Any path outside those roots requires:
```zig
.permissions = .{ "filesystem" },
```
The runtime is authoritative. Before checking, it resolves the target when it exists, or resolves the deepest existing parent and normalizes the missing suffix. Existing symlinks are followed, so a symlink inside an app directory that points outside is external and requires the permission. `..` cannot escape an allowed root.
`native check` also reports NS1074 for certainly-external literal paths, but dynamic paths are decided only by the runtime. File pickers therefore require the `filesystem` permission because the selected user file is normally outside app-owned directories.
Directory enumeration, file watching, chmod/permissions, and per-path grant prompts are not part of this API.
+1 -1
View File
@@ -437,7 +437,7 @@ case "fetched":
Response bodies are binary-safe and bounded (256 KiB; longer arrives cut with `truncated = true`), the whole exchange honors a per-fetch timeout (default 30 s), and cancelling a fetch delivers exactly one `cancelled` Msg with nothing after it.
Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state — session snapshots, transcripts — without smuggling an `Io` handle from `main` into `update`. Bounded (1 KiB paths, 1 MiB files), key-based, one terminal Msg per operation with an explicit outcome (`ok`, `not_found`, `io_failed`, `truncated` — an over-bound read's own outcome, so a cut JSON snapshot cannot parse as whole — `rejected`, `cancelled`); writes create missing parent directories and replace the file whole:
Files ride the same channel without smuggling an `Io` handle from `main` into `update`. Whole-file reads and writes remain bounded at 1 MiB; `appendFile` and `statFile` handle logs and planning, while the dedicated streaming family carries large imports and atomic exports. External paths require the `filesystem` permission; app-owned directories are exempt after symlink-safe normalization. See [Files & Streaming](/docs/files).
```zig
.save => fx.writeFile(.{
+1 -1
View File
@@ -39,7 +39,7 @@ Every app declares `permissions` and `capabilities` in `app.zon` — the runtime
</tr>
<tr>
<td><code>filesystem</code></td>
<td>File system access from bridge commands</td>
<td>Raw file effects and bridge commands outside this app's resolved data/config/cache/state/logs/temp roots; symlinks are resolved before the runtime check</td>
</tr>
<tr>
<td><code>clipboard</code></td>
+10 -2
View File
@@ -279,6 +279,14 @@ The runtime interprets the command after the model commits and dispatches any re
<td><code>Cmd.writeFile(path, bytes, &#123; key?, ok, err &#125;)</code></td>
<td>Write a whole file (parents created, replaced whole); <code>ok</code> carries no payload — a successful write has nothing to report</td>
</tr>
<tr>
<td><code>Cmd.appendFile</code> / <code>Cmd.statFile</code></td>
<td>Append one bounded payload, or inspect <code>&#123; exists, size, mtimeMs &#125;</code> before choosing how to read a file</td>
</tr>
<tr>
<td><code>Cmd.readFileStream</code> / <code>Cmd.writeFileStream</code> + <code>writeFileChunk</code>/<code>writeFileClose</code></td>
<td>Read 256-KiB chunks without a total-size cliff, or build an atomic export one acknowledged chunk at a time — see <a href="/docs/files">Files &amp; Streaming</a></td>
</tr>
<tr>
<td><code>Cmd.fetch(spec, &#123; key?, ok, err &#125;)</code></td>
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>&#123; status, body &#125;</code> (a 404 is still <code>ok</code> — a delivered response), <code>err</code> the transport reason</td>
@@ -362,11 +370,11 @@ The runtime interprets the command after the model commits and dispatches any re
</tbody>
</table>
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for raw host results and errors, a generated service result record, one number field for timer fires and a streaming fetch's terminal status, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed engine effect — buffered named engine ops, `Cmd.delay`, a raw `Cmd.request` to an embedder host command — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it with no message. Live `Cmd.spawn`, streaming-fetch, and service keys (buffered or streaming) reject a duplicate (`err` gets `rejected`) so two calls can never splice into one result or stream; cancel first if you mean to supersede — cancelling a buffered service request produces no message, while cancelling a live stream routes `cancelled` to `err`. A streaming fetch whose line is cut or dropped also ends with `err: truncated`, never a misleading successful status. Every routed `err` arm receives a machine-readable reason.
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for raw host results and errors, a generated service result record, one number field for timer fires and stream totals, no fields for write acknowledgments, and one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Buffered engine effects and streamed file reads replace a live same-key predecessor and cancel silently. Live `Cmd.spawn`, streaming-fetch, streaming-service, and streamed write-sink keys reject duplicates so two producers cannot splice; cancelling those is loud through `err: cancelled`. A streaming fetch whose line is cut or dropped also ends with `err: truncated`, never a misleading successful status. Every routed `err` arm receives a machine-readable reason.
Platform state stays on the same effect boundary. `Cmd.openExternalUrl(url)` enforces [`security.navigation.external_links`](/docs/security#external-links) before entering the browser; `Cmd.revealPath(path)` uses the desktop file manager. Credential operations take byte `service` and `account` identifiers plus the standard `{ key?, ok, err }` route: set/delete return empty bytes on `ok`, get returns the secret, and a missing item routes `not_found`. `Cmd.formatLocalTime(timestampMs, "date" | "time" | "datetime", route)` returns localized UTF-8 bytes using the current host locale and time zone. That formatting is deliberately a Cmd—not a pure helper—so session recording captures the observed text and replay never re-reads ambient locale or timezone state.
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. `Cmd.readFile` and `Cmd.writeFile` remain for user-visible files, exports, and blobs; when using them, request the framework-provided app-data directory through `envMsgs` instead of depending on process cwd.
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. Raw file commands remain for user-visible files, exports, and blobs; [Files & Streaming](/docs/files) covers their bounds, atomic sink protocol, replay, and `filesystem` permission gate.
Independent byte records use `Cmd.store`: declare the `store` capability, then route set/get/delete/scan/setMany results back to Msg arms. The engine owns the app-data path, SQLite schema, atomic batches, pagination, and replay boundary. See [Record Store](/docs/record-store).
+1
View File
@@ -44,6 +44,7 @@ const unprefixedNavSections: NavSection[] = [
{ name: "Model Persistence", href: "/persistence" },
{ name: "Record Store", href: "/record-store" },
{ name: "Relational SQLite", href: "/sqlite" },
{ name: "Files & Streaming", href: "/files" },
],
},
{
+1
View File
@@ -16,6 +16,7 @@ export const PAGE_TITLES: Record<string, string> = {
persistence: "Model Persistence",
"record-store": "Record Store",
sqlite: "Relational SQLite",
files: "Files & Streaming",
terminal: "Terminal",
state: "State & Data Flow",
theming: "Theming",
+35 -1
View File
@@ -1,5 +1,5 @@
//! Decoder over the app-core Cmd/Sub wire format (rt.zig, cmd_format_version
//! 4), shared by the ts-track behavioral harnesses. The graders copy this
//! 5), shared by the ts-track behavioral harnesses. The graders copy this
//! file next to each case's harness so assertions read decoded ops — "a
//! fetch with key `feed` targeting this URL", "the delay re-armed" — instead
//! of hand-built byte strings, which keeps harnesses lenient about the parts
@@ -29,6 +29,12 @@ pub const Op = union(enum) {
cancel: struct { key: []const u8 },
read_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
write_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
append_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
stat_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
read_file_stream: struct { key: []const u8, chunk_tag: u8, done_tag: u8, err_tag: u8, path: []const u8 },
write_file_stream: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
write_file_chunk: struct { key: []const u8, ok_tag: u8, err_tag: u8, bytes: []const u8 },
write_file_close: struct { key: []const u8, ok_tag: u8, err_tag: u8 },
fetch: Fetch,
fetch_stream: FetchStream,
clip_write: struct { bytes: []const u8 },
@@ -546,6 +552,34 @@ pub const CmdIter = struct {
}
break :blk .{ .store_set_many = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .count = count, .entry_bytes = b[entries_start..off] } };
},
0x2B => blk: {
const head = routedHead(b, &off);
break :blk .{ .append_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off), .bytes = longBytes(b, &off) } };
},
0x2C => blk: {
const head = routedHead(b, &off);
break :blk .{ .stat_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
0x2D => blk: {
const key = shortBytes(b, &off);
const chunk_tag = b[off];
const done_tag = b[off + 1];
const err_tag = b[off + 2];
off += 3;
break :blk .{ .read_file_stream = .{ .key = key, .chunk_tag = chunk_tag, .done_tag = done_tag, .err_tag = err_tag, .path = longBytes(b, &off) } };
},
0x2E => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_stream = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
0x2F => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_chunk = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .bytes = longBytes(b, &off) } };
},
0x30 => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_close = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err } };
},
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
};
self.off = off;
+1 -1
View File
@@ -5,7 +5,7 @@
.description = "Choose a folder and edit its source files in a native two-pane window.",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command", "dialog" },
.permissions = .{ "view", "command", "dialog", "filesystem" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shortcuts = .{
.{ .id = "save-file", .key = "s", .modifiers = .{ "primary" } },
+1
View File
@@ -52,6 +52,7 @@ const max_status_bytes: usize = 192;
const app_permissions = [_][]const u8{
native_sdk.security.permission_command,
native_sdk.security.permission_dialog,
native_sdk.security.permission_filesystem,
native_sdk.security.permission_view,
};
const shell_views = [_]native_sdk.ShellView{
+1 -1
View File
@@ -5,7 +5,7 @@
.description = "A native markdown reader with GPU-rendered text.",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command", "network" },
.permissions = .{ "view", "command", "network", "filesystem" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
+2
View File
@@ -74,6 +74,7 @@ pub const link_key: u64 = 5;
const app_permissions = [_][]const u8{
native_sdk.security.permission_command,
native_sdk.security.permission_filesystem,
native_sdk.security.permission_network,
native_sdk.security.permission_view,
};
@@ -662,6 +663,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
},
else => model.setNote("Save failed: {s}", .{@tagName(result.outcome)}),
},
else => model.setNote("Unexpected file operation: {s}", .{@tagName(result.op)}),
},
.recent_done => |result| {
if (result.op == .read and result.outcome == .ok) model.restoreRecent(result.bytes);
+11 -11
View File
@@ -205,10 +205,10 @@ pub const Msg = union(enum) {
/// chrome/appearance/effect channels — never bound in markup, so the
/// dead-state lint must not ask for an on-* event.
pub const view_unbound = .{
"select_folder_at", "next_note", "prev_note", "delete_note",
"copy_note", "open_rename_folder", "dismiss",
"system_scheme", "chrome_changed", "refresh_tick",
"save_tick", "store_done", "clipboard_done",
"select_folder_at", "next_note", "prev_note", "delete_note",
"copy_note", "open_rename_folder", "dismiss", "system_scheme",
"chrome_changed", "refresh_tick", "save_tick", "store_done",
"clipboard_done",
};
};
@@ -284,13 +284,12 @@ pub const Model = struct {
/// keeps `native check`'s dead-state lint quiet without weakening it
/// for real drift.
pub const view_unbound = .{
"folders", "folder_count", "notes", "note_count",
"next_folder_id", "next_note_id", "selected_folder",
"search_buffer", "dialog", "folder_field",
"dialog_folder", "store_path_storage", "store_path_len",
"store_write_inflight", "save_pending", "system_scheme",
"clock", "now_ms", "status_storage", "status_len",
"liveNoteCount", "deletedNoteCount", "searching", "status",
"folders", "folder_count", "notes", "note_count",
"next_folder_id", "next_note_id", "selected_folder", "search_buffer",
"dialog", "folder_field", "dialog_folder", "store_path_storage",
"store_path_len", "store_write_inflight", "save_pending", "system_scheme",
"clock", "now_ms", "status_storage", "status_len",
"liveNoteCount", "deletedNoteCount", "searching", "status",
"storePath", "hovered_note",
};
@@ -1061,6 +1060,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
else => model.setStatus("Save failed: {s}", .{@tagName(result.outcome)}),
}
},
else => model.setStatus("Unexpected file operation: {s}", .{@tagName(result.op)}),
},
.clipboard_done => |result| {
if (result.op != .write) return;
+2 -1
View File
@@ -191,7 +191,8 @@ function pushedLevel(levels: readonly number[], level: number): readonly number[
}
// PCM16 mono -> a canonical little-endian RIFF/WAVE file. The recording
// limit keeps the result below Cmd.writeFile's 1 MiB payload ceiling.
// This sample still buffers one bounded memo in Model for immediate playback;
// longer recordings should stream chunks directly to an atomic file sink.
function buildWav(chunks: readonly PcmChunk[], dataBytes: number): Uint8Array {
const out = new Uint8Array(44 + dataBytes);
const riffSize = 36 + dataBytes;
+34
View File
@@ -143,6 +143,16 @@ export interface WriteRoute<M extends Msgish> {
readonly err: M["kind"];
}
export interface FileReadStreamRoute<M extends Msgish> {
readonly key?: string;
readonly chunk: M["kind"];
readonly done: M["kind"];
readonly err: M["kind"];
}
export interface FileStatArm { readonly exists: boolean; readonly size: number; readonly mtimeMs: number; }
export interface FileStatRoute<M extends Msgish> { readonly key?: string; readonly ok: M["kind"]; readonly err: M["kind"]; }
export interface StoreScanOptions {
readonly limit?: number;
readonly after?: string | Uint8Array;
@@ -355,6 +365,12 @@ export type CmdData =
readonly path: Uint8Array;
readonly bytes: Uint8Array;
}
| { readonly op: "append_file"; readonly key: string; readonly okKind: string; readonly errKind: string; readonly path: Uint8Array; readonly bytes: Uint8Array }
| { readonly op: "stat_file"; readonly key: string; readonly okKind: string; readonly errKind: string; readonly path: Uint8Array }
| { readonly op: "read_file_stream"; readonly key: string; readonly chunkKind: string; readonly doneKind: string; readonly errKind: string; readonly path: Uint8Array }
| { readonly op: "write_file_stream"; readonly key: string; readonly okKind: string; readonly errKind: string; readonly path: Uint8Array }
| { readonly op: "write_file_chunk"; readonly key: string; readonly okKind: string; readonly errKind: string; readonly bytes: Uint8Array }
| { readonly op: "write_file_close"; readonly key: string; readonly okKind: string; readonly errKind: string }
| {
readonly op: "store_set";
readonly key: string;
@@ -700,6 +716,24 @@ export const Cmd = {
writeFile(path: Uint8Array, bytes: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
},
appendFile(path: Uint8Array, bytes: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return { op: "append_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
},
statFile(path: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return { op: "stat_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path };
},
readFileStream(path: Uint8Array, route: { readonly key?: string; readonly chunk: string; readonly done: string; readonly err: string }): CmdData {
return { op: "read_file_stream", key: route.key ?? "", chunkKind: route.chunk, doneKind: route.done, errKind: route.err, path };
},
writeFileStream(key: string, path: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return { op: "write_file_stream", key, okKind: route.ok, errKind: route.err, path };
},
writeFileChunk(key: string, bytes: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return { op: "write_file_chunk", key, okKind: route.ok, errKind: route.err, bytes };
},
writeFileClose(key: string, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
return { op: "write_file_close", key, okKind: route.ok, errKind: route.err };
},
store: {
set(storeKey: string, bytes: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
+60
View File
@@ -136,6 +136,23 @@ export interface WriteRoute<M extends Msgish> {
readonly ok: EmptyKind<M>;
readonly err: BytesKind<M>;
}
export interface FileReadStreamRoute<M extends Msgish> {
readonly key?: string;
readonly chunk: BytesKind<M>;
readonly done: TimestampKind<M>;
readonly err: BytesKind<M>;
}
export interface FileStatArm {
readonly exists: boolean;
readonly size: number;
readonly mtimeMs: number;
}
export type FileStatKind<M extends Msgish> = M extends Msgish ? [Exclude<keyof M, "kind">] extends [keyof FileStatArm] ? [keyof FileStatArm] extends [Exclude<keyof M, "kind">] ? M extends Msgish & FileStatArm ? M["kind"] : never : never : never : never;
export interface FileStatRoute<M extends Msgish> {
readonly key?: string;
readonly ok: FileStatKind<M>;
readonly err: BytesKind<M>;
}
export interface StoreScanOptions {
readonly limit?: number;
readonly after?: string | Uint8Array;
@@ -284,6 +301,43 @@ export type Cmd<M extends Msgish> = {
readonly errKind: string;
readonly path: Uint8Array;
readonly bytes: Uint8Array;
} | {
readonly op: "append_file";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
readonly bytes: Uint8Array;
} | {
readonly op: "stat_file";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
} | {
readonly op: "read_file_stream";
readonly key: string;
readonly chunkKind: string;
readonly doneKind: string;
readonly errKind: string;
readonly path: Uint8Array;
} | {
readonly op: "write_file_stream";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
} | {
readonly op: "write_file_chunk";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly bytes: Uint8Array;
} | {
readonly op: "write_file_close";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
} | {
readonly op: "store_set";
readonly key: string;
@@ -500,6 +554,12 @@ export declare const Cmd: {
cancel(key: string): Cmd<never>;
readFile<M extends Msgish>(path: Uint8Array, route: RequestRoute<M>): Cmd<M>;
writeFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
appendFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
statFile<M extends Msgish>(path: Uint8Array, route: FileStatRoute<M>): Cmd<M>;
readFileStream<M extends Msgish>(path: Uint8Array, route: FileReadStreamRoute<M>): Cmd<M>;
writeFileStream<M extends Msgish>(key: string, path: Uint8Array, route: WriteRoute<M>): Cmd<M>;
writeFileChunk<M extends Msgish>(key: string, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
writeFileClose<M extends Msgish>(key: string, route: WriteRoute<M>): Cmd<M>;
store: {
set<M extends Msgish>(storeKey: string, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
get<M extends Msgish>(storeKey: string, route: RequestRoute<M>): Cmd<M>;
+113 -8
View File
@@ -10,7 +10,7 @@
// return path (NS1017) — they never live in the Model, in a Msg, in a local,
// or in a helper.
//
// The v3 command set:
// The v4 command set:
//
// Cmd.none no effects (what a bare `return model` means)
// Cmd.persist() ask the host to persist the committed model
@@ -32,10 +32,11 @@
// re-issuing a live key replaces it, and
// Cmd.cancel(key) drops it.
// Cmd.cancel(key) drop the in-flight keyed effect — request,
// readFile/writeFile/fetch/clipboardRead, or
// readFile/writeFile/readFileStream/fetch/
// clipboardRead, or
// delay — SILENTLY (no terminal arm dispatch).
// Aimed at a live spawn, streaming fetch, or
// streaming service it
// streaming service or write-file sink it
// stays LOUD: the err arm runs with
// "cancelled" — ending a stream is observable
// Cmd.batch([a, b, ...]) several commands from one dispatch
@@ -53,6 +54,11 @@
// whole-file write (parents created, replaced
// whole); ok arm carries NOTHING (an arm with
// no payload fields), err arm the reason bytes
// Cmd.appendFile / statFile bounded append and size/mtime/existence probe
// Cmd.readFileStream 256-KiB chunks, then done(total) or err
// Cmd.writeFileStream / writeFileChunk / writeFileClose
// atomic streamed sink; chunks are acknowledged
// in order and close installs the destination
// Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })
// buffered HTTP(S) exchange; ok arm carries a
// two-field record — one number field (the real
@@ -255,10 +261,12 @@
// The keyed-effect discipline is ONE rule: a keyed effect REPLACES its live
// predecessor (the superseded effect's result is dropped — no message), and
// Cmd.cancel drops it silently. That holds for request, readFile, writeFile,
// buffered fetch, clipboardRead, and delay alike. Live spawn and streaming-
// fetch keys are the exceptions: a duplicate REJECTS the new stream (err arm
// "rejected") so results from two sources are never spliced together. Cancel
// either stream first; its err arm runs with "cancelled".
// readFileStream, buffered fetch, clipboardRead, and delay alike. A reissued
// readFileStream key retires the old read silently and starts the replacement.
// Live spawn, streaming-fetch, streaming-service, and write-file SINK keys are
// the exceptions: a duplicate REJECTS the new stream/sink so two producers are
// never spliced. Cancelling a sink is loud (`err: cancelled`) because a
// half-written export is observable; cancelling a read stream is silent.
//
// `Sub` is the recurring-effects surface: an app may export
// `subscriptions(model): Sub<Msg>` returning declarative descriptors the
@@ -850,6 +858,36 @@ export interface WriteRoute<M extends Msgish> {
readonly err: BytesKind<M>;
}
/// Streaming read routing: zero or more 256-KiB `chunk` messages, then one
/// `done` carrying the total byte count, or one `err` carrying a closed file
/// outcome.
export interface FileReadStreamRoute<M extends Msgish> {
readonly key?: string;
readonly chunk: BytesKind<M>;
readonly done: TimestampKind<M>;
readonly err: BytesKind<M>;
}
export interface FileStatArm {
readonly exists: boolean;
readonly size: number;
readonly mtimeMs: number;
}
export type FileStatKind<M extends Msgish> = M extends Msgish
? [Exclude<keyof M, "kind">] extends [keyof FileStatArm]
? [keyof FileStatArm] extends [Exclude<keyof M, "kind">]
? M extends Msgish & FileStatArm ? M["kind"] : never
: never
: never
: never;
export interface FileStatRoute<M extends Msgish> {
readonly key?: string;
readonly ok: FileStatKind<M>;
readonly err: BytesKind<M>;
}
/// Pagination controls for `Cmd.store.scan`. `limit` defaults to 100 and is
/// bounded at 256. `after` is the opaque key cursor returned by the previous
/// page; omit it for the first page.
@@ -1099,6 +1137,49 @@ export type Cmd<M extends Msgish> =
readonly path: Uint8Array;
readonly bytes: Uint8Array;
}
| {
readonly op: "append_file";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
readonly bytes: Uint8Array;
}
| {
readonly op: "stat_file";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
}
| {
readonly op: "read_file_stream";
readonly key: string;
readonly chunkKind: string;
readonly doneKind: string;
readonly errKind: string;
readonly path: Uint8Array;
}
| {
readonly op: "write_file_stream";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
}
| {
readonly op: "write_file_chunk";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
readonly bytes: Uint8Array;
}
| {
readonly op: "write_file_close";
readonly key: string;
readonly okKind: string;
readonly errKind: string;
}
| {
readonly op: "store_set";
readonly key: string;
@@ -1511,7 +1592,7 @@ export const Cmd = {
/// Drop the in-flight keyed effect — request, named engine op, or delay —
/// with this key, if any, SILENTLY (neither routing arm is dispatched for
/// it). Live spawn, streaming-fetch, and streaming-service operations are the exceptions:
/// it). Live spawn, streaming-fetch, streaming-service, and write-file-sink operations are the exceptions:
/// cancel ends the stream and its err arm runs with "cancelled".
cancel(key: string): Cmd<never> {
return { op: "cancel", key };
@@ -1531,6 +1612,30 @@ export const Cmd = {
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
},
appendFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M> {
return { op: "append_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
},
statFile<M extends Msgish>(path: Uint8Array, route: FileStatRoute<M>): Cmd<M> {
return { op: "stat_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path };
},
readFileStream<M extends Msgish>(path: Uint8Array, route: FileReadStreamRoute<M>): Cmd<M> {
return { op: "read_file_stream", key: route.key ?? "", chunkKind: route.chunk, doneKind: route.done, errKind: route.err, path };
},
writeFileStream<M extends Msgish>(key: string, path: Uint8Array, route: WriteRoute<M>): Cmd<M> {
return { op: "write_file_stream", key, okKind: route.ok, errKind: route.err, path };
},
writeFileChunk<M extends Msgish>(key: string, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M> {
return { op: "write_file_chunk", key, okKind: route.ok, errKind: route.err, bytes };
},
writeFileClose<M extends Msgish>(key: string, route: WriteRoute<M>): Cmd<M> {
return { op: "write_file_close", key, okKind: route.ok, errKind: route.err };
},
/// Capability-gated, engine-owned per-record storage. Keys are UTF-8 text
/// up to 512 bytes; values are bytes up to 1 MiB. Results remain effects:
/// they arrive through the supplied Msg routes after update commits.
+22
View File
@@ -2624,6 +2624,28 @@ export class SubsetChecker {
}
}
// NS1074 — best-effort literal-path lint for the runtime filesystem
// gate. App-dir paths normally arrive as bytes from envMsgs and remain
// dynamic here; a literal absolute path or a lexical parent escape is
// certainly external and should name the missing permission now.
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
["readFile", "writeFile", "appendFile", "statFile", "readFileStream", "writeFileStream"].includes(node.expression.name.text) &&
ts.isIdentifier(node.expression.expression) &&
this.cmdNames.has(node.expression.expression.text) &&
this.isSdkReference(node.expression.expression) &&
!this.permissions.has("filesystem")
) {
const pathArg = node.expression.name.text === "writeFileStream" ? node.arguments[1] : node.arguments[0];
if (pathArg && ts.isCallExpression(pathArg) && ["asciiBytes", "utf8Bytes"].includes(this.sdkRootFunctionName(pathArg.expression) ?? "")) {
const literal = pathArg.arguments[0];
if (literal && ts.isStringLiteral(literal) && (/^(?:\/|[A-Za-z]:[\\/])/.test(literal.text) || literal.text.split(/[\\/]+/).includes(".."))) {
this.report("NS1074", `\`Cmd.${node.expression.name.text}\` uses a literal path outside the app-directory exemption without the \`filesystem\` permission.`, pathArg);
}
}
}
// NS1069 — the nested record-store factories remain capability-bound;
// recognizing the SDK Cmd symbol (rather than its spelling alone) keeps
// local objects named Cmd out of this cross-file contract check.
+1 -1
View File
@@ -667,7 +667,7 @@ class ContractEmitter {
return (
"{\n" +
' "format": 1,\n' +
' "wire_version": 4,\n' +
' "wire_version": 5,\n' +
' "abi_version": 2,\n' +
' "compiler_version": "0.0.1",\n' +
` "entry": ${js(this.entry)},\n` +
+18
View File
@@ -1792,6 +1792,24 @@ function performCmd(cmd: Cmdish): void {
case "db_exec":
performDbCmd(cmd);
return;
case "read_file":
case "write_file":
case "append_file":
case "stat_file":
case "read_file_stream":
case "write_file_stream":
case "write_file_chunk":
case "write_file_close": {
// The logic-only host deliberately performs no ambient filesystem IO;
// it still names the runtime policy so dev transcripts cannot imply
// these commands bypass the manifest gate.
const details = Object.entries(cmd)
.filter(([k]) => k !== "op")
.map(([k, v]) => `${k}=${JSON.stringify(jsonable(v))}`)
.join(" ");
say(`cmd ${cmd.op} ${details}`.trimEnd() + " (not performed by the virtual host; runtime requires filesystem permission outside app dirs)");
return;
}
case "show_notification": {
const details = Object.entries(cmd)
.filter(([k]) => k !== "op")
+7 -7
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
// Kept in lockstep with `zig build print-pins`; the Node test suite checks
// both values so a runtime wire change cannot silently strand dev-host
// recordings.
export const journalFormatFingerprint = 0xeab6fb36d9dee5a7n;
export const journalFormatFingerprint = 0xa04fc349185092f3n;
export const automationProtocolFingerprint = 0x59d66f39803fd602n;
const requestKeyBase = 0x5453525100000000n;
@@ -39,10 +39,10 @@ function defaultEffect(kind, key, payload, options = {}) {
return concat([
new Uint8Array([kind]), u64(key), stringBytes(payload), stringBytes(new Uint8Array(0)),
new Uint8Array([0]), u32(options.dropped ?? 0), i32(options.code ?? 0), new Uint8Array([0, 0, 0]), u16(0),
// fetch outcome, file op/outcome, clipboard op/outcome, timer outcome
new Uint8Array([0, 0, 0, 1, 0]), u64(0), new Uint8Array([0]), i64(0),
// audio defaults: position, zero timing/state, 32 zero bands
new Uint8Array([1]), u64(0), u64(0), new Uint8Array([0, 0]), new Uint8Array(32),
// fetch outcome; file op/event/outcome + total/mtime/exists; clipboard op/outcome; timer outcome
new Uint8Array([0, 0, 0, 0]), u64(0), i64(0), new Uint8Array([0, 0, 1, 0]), u64(0), new Uint8Array([0]), i64(0),
// audio defaults: position, zero timing/state; file blob address; 32 zero bands
new Uint8Array([1]), u64(0), u64(0), new Uint8Array([0, 0]), new Uint8Array(16), u64(0), new Uint8Array(32),
// image defaults + 16-byte blob hash
new Uint8Array([0]), u64(0), u64(0), new Uint8Array(16), u64(0),
// channel event + cumulative drops
@@ -121,8 +121,8 @@ function decodeEffect(payload) {
const dropped = r.u32();
const code = r.i32();
r.byte(); r.byte(); r.byte(); r.u16();
r.byte(); r.byte(); r.byte(); r.byte(); r.byte(); r.u64(); r.byte(); r.i64();
r.byte(); r.u64(); r.u64(); r.byte(); r.byte(); r.take(32);
r.byte(); r.byte(); r.byte(); r.byte(); r.u64(); r.i64(); r.byte(); r.byte(); r.byte(); r.byte(); r.u64(); r.byte(); r.i64();
r.byte(); r.u64(); r.u64(); r.byte(); r.byte(); r.take(16); r.u64(); r.take(32);
r.byte(); r.u64(); r.u64(); r.take(16); r.u64();
const channelKind = r.byte();
const channelDroppedTotal = r.u32();
+6
View File
@@ -460,6 +460,12 @@ export const rules = {
fix: "Use `Cmd.credentials.set`, `Cmd.credentials.get`, or `Cmd.credentials.delete` instead of spelling `core.credentials.*` through `Cmd.request`.",
why: "The typed factories own the bounded credential record encoding; reserving their wire namespace keeps arbitrary request bytes from being mistaken for secrets or keys.",
},
NS1074: {
id: "NS1074",
title: "external file paths require filesystem permission",
fix: "Add `\"filesystem\"` to app.zon's `permissions`, or keep raw file effects under a path delivered from `NATIVE_SDK_APP_DATA_DIR`.",
why: "The runtime canonicalizes raw paths and refuses access outside this app's data/config/cache/state/logs/temp roots unless the manifest grants filesystem access; catching literal external paths at check time avoids shipping a guaranteed rejection.",
},
NS1420: {
id: "NS1420",
title: "declare stable SQL in src/queries.sql",
+36
View File
@@ -159,6 +159,42 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
assert.equal(result.warnings.some((d) => d.id === "NS1071"), false);
});
test("NS1074 catches certainly-external literal file paths without filesystem permission", () => {
const source = `
import { Cmd, asciiBytes } from "@native-sdk/core";
export interface Model { readonly bytes: Uint8Array; }
export type Msg =
| { readonly kind: "go" }
| { readonly kind: "wrote" }
| { readonly kind: "loaded"; readonly bytes: Uint8Array }
| { readonly kind: "stat"; readonly exists: boolean; readonly size: number; readonly mtimeMs: number }
| { readonly kind: "done"; readonly total: number }
| { readonly kind: "failed"; readonly reason: Uint8Array };
export function initialModel(): Model { return { bytes: asciiBytes("x") }; }
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "go": return [model, Cmd.batch([
Cmd.readFile(asciiBytes("/tmp/input"), { ok: "loaded", err: "failed" }),
Cmd.writeFile(asciiBytes("../output"), model.bytes, { ok: "wrote", err: "failed" }),
Cmd.appendFile(asciiBytes("logs/local"), model.bytes, { ok: "wrote", err: "failed" }),
Cmd.statFile(asciiBytes("C:\\\\Users\\\\outside"), { ok: "stat", err: "failed" }),
Cmd.readFileStream(asciiBytes("safe/import"), { chunk: "loaded", done: "done", err: "failed" }),
Cmd.writeFileStream("sink", asciiBytes("/tmp/export"), { ok: "wrote", err: "failed" }),
])];
case "wrote":
case "loaded":
case "stat":
case "done":
case "failed": return model;
}
}
`;
const denied = check(source);
assert.equal(denied.diagnostics.filter((d) => d.id === "NS1074").length, 4);
const granted = check(source, { permissions: ["filesystem"] });
assert.equal(granted.diagnostics.some((d) => d.id === "NS1074"), false);
});
test("NS1033 validates app.zon persistence restore routes against Msg", () => {
const source = `
import { Cmd } from "@native-sdk/core";
+1 -1
View File
@@ -62,7 +62,7 @@ export function update(model: Model, msg: Msg): Model {
test("a small core's contract carries types, arms, slots, and channels", () => {
const doc = contractOf(smallCore);
assert.equal(doc.format, 1);
assert.equal(doc.wire_version, 4);
assert.equal(doc.wire_version, 5);
assert.equal(doc.abi_version, 2);
assert.equal(doc.entry, "src/core.ts");
assert.equal(doc.model, "Model");
+4 -2
View File
@@ -681,7 +681,7 @@ Streaming responses (`.response = .stream`) frame the body into `on_line` Msgs a
Stream rules: each body line is one `on_line` Msg (same payload type and copy rule as spawn lines; `max_line_bytes` mirrors the spawn override with the same 256 KiB ceiling); the terminal `on_response` Msg carries the real HTTP status with an empty body; `fx.cancel(key)` mid-stream stops the lines and delivers exactly one `.cancelled` terminal; the whole-exchange `timeout_ms` covers the stream's full lifetime, so raise it for long-running commands; lines dropped on a full queue that no later line reported ride the terminal's `response.dropped_before`. In the fake executor, `feedLine` feeds a stream fetch's lines and `feedResponse(key, status, "")` delivers its terminal.
`fx.writeFile` / `fx.readFile` are TEA-friendly file persistence — session snapshots, app state — without smuggling an `Io` handle from `main` into `update`. Same discipline as spawn and fetch: bounded, key-based (shared key space and 16 slots), exactly one terminal Msg with an explicit outcome:
`fx.writeFile` / `fx.readFile` are TEA-friendly raw file effects for user-visible files, exports, and blobs — app state belongs in the engine-owned storage tiers. Same discipline as spawn and fetch: bounded, key-based, and explicit outcomes:
```zig
pub const Msg = union(enum) {
@@ -708,8 +708,10 @@ pub const Msg = union(enum) {
File rules:
- `result.outcome` is explicit: `.ok` (a read's whole content in `result.bytes`; a write fully on disk), `.not_found` (reads only — writes create the path, parent directories included), `.io_failed` (permissions, path is a directory, disk), `.truncated` (the file exceeds the 1 MiB `max_effect_file_bytes`; `result.bytes` is the first bound bytes — its own outcome, not a flag, because a cut JSON snapshot must not parse as whole), `.rejected` (never ran: slots busy, duplicate key, empty/over-long path, write bytes over the bound — an over-bound WRITE is rejected outright since a partial write would corrupt the file), `.cancelled`.
- `result.outcome` is explicit: `.ok`, `.not_found`, `.io_failed`, `.truncated`, `.rejected`, `.cancelled`, `.sink_missing`, `.out_of_order`, or `.disk_full`.
- Writes replace the file whole; `writeFile` bytes are copied at call time so the caller's buffer is immediately reusable. Reads deliver drain-scratch bytes — copy what the model keeps.
- `appendFile` appends one payload up to 1 MiB; `statFile` returns existence, size, and mtime. `readFileStream` emits 256-KiB chunks then `.done(total)`. `writeFileStream` opens an atomic sink; acknowledge each `writeFileChunk` before sending the next, then `writeFileClose` syncs and atomically installs the destination. Streaming owns four slots separate from the general sixteen.
- Raw paths under this app's resolved data/config/cache/state/logs/temp roots need no grant. Every external path requires the `filesystem` permission. The runtime resolves existing parents and symlinks before checking, so an in-root symlink pointing out is external.
- In the fake executor: `pendingFileAt(0)` records `key`/`op`/`path`/`bytes` for assertions; `feedFileResult(key, .ok, "{...}")` answers a read (over-bound content is cut and rewritten to `.truncated`, mirroring the real reader), `feedFileResult(key, .ok, "")` acknowledges a write; failure outcomes pass through as fed.
`fx.credentialsSet` / `fx.credentialsGet` / `fx.credentialsDelete` are the only place authentication tokens, passwords, and similar app secrets belong. Declare both `.capabilities = .{ "credentials" }` and `.permissions = .{ "credentials" }` in `app.zon`; the stable app id is the OS keychain service namespace, while the supplied key is its account. Never copy a fetched secret into Model: consume the drain-scratch `result.bytes` immediately while constructing the next effect, so persistence, state fingerprints, and diagnostics cannot capture it.
+3 -2
View File
@@ -121,7 +121,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
}
```
The command set (Cmd wire format v4):
The command set (Cmd wire format v5):
- `Cmd.none` — no effects; returning a bare `Model` is sugar for `[model, Cmd.none]`.
- `Cmd.persist()` — snapshot the just-committed Model through the engine-owned store. Requires `"persist"` in `app.zon` capabilities plus `.persist = .{ .version, .restore = .{ .ok, .none, .err } }`; the host owns canonical encoding, debounce/coalescing, atomic app-data placement, backup recovery, and replay.
@@ -129,7 +129,7 @@ The command set (Cmd wire format v4):
- `Cmd.host(name, ...args)` — a fire-and-forget host command by literal name; the host decides what the name means. Args are numbers, OR exactly one bytes payload: a `Uint8Array` (`Cmd.host("clipboard.write", model.draft)`) or a flat inline record of number/boolean/`Uint8Array` fields (`Cmd.host("cfg.save", { gain: model.gain, on: model.muted, label: asciiBytes("main") })`) — the record lowers to one bytes payload from your types at build time, byte-identical under node and native. Anything else (a smuggled string, a nested record, a payload plus extra args) is a taught error (NS1020/NS1026).
- `Cmd.request(name, payload, { key?, ok, err })` — a routed host command: the host performs `name` with the payload (same bytes/record rules) and dispatches exactly one result back to you as an ordinary Msg — the `ok` arm with the result bytes on success, or the `err` arm with the error bytes on failure. Both arms must carry exactly one `Uint8Array` field (`{ kind: "loaded", body: Uint8Array }`), checked by tsc and taught by NS1027. The routing is data — string-literal arm names, never callbacks — so the result decoder derives from your Msg types at build time. The optional `key` (a string literal) names the in-flight effect: issuing a request whose key is already in flight replaces it (the old result is dropped), which is the debounce/exactly-one-in-flight discipline.
- Typed app services are the deliberate record-valued arm of this family: declare shared data shapes outside `src/services/`, import generated constructors from `@native-sdk/services`, and let those constructors encode requests and prove the typed success Msg arm. See `native skills get ts-services`. Raw `Cmd.request` remains bytes-oriented.
- `Cmd.cancel(key)` — drop the in-flight keyed effect with that key, silently: a cancelled raw request, buffered named engine op (`readFile`/`writeFile`/`fetch`/`clipboardRead`), or armed delay dispatches NEITHER arm — its result is simply dropped. Live spawn, streaming-fetch, and streaming-service operations are the exceptions: cancel ends the stream and its `err` arm dispatches with `cancelled`, loud because an app may already have handled earlier stream messages.
- `Cmd.cancel(key)` — drop the in-flight keyed effect with that key, silently: a cancelled raw request, buffered named engine op (`readFile`/`writeFile`/`fetch`/`clipboardRead`), streamed file read, or armed delay dispatches NEITHER arm. Live spawn, streaming-fetch, streaming-service, and streamed write sinks are the exceptions: cancel ends them through `err: cancelled`; a half-written sink is observable and must never disappear silently.
- `Cmd.batch([a, b])` — several commands from one dispatch, performed in order.
### The named engine ops
@@ -141,6 +141,7 @@ These map directly onto the host's effect engine — files, HTTP, the clipboard,
- `Cmd.db.query(sql, params, { key?, page, done, err })` / `Cmd.db.exec(statements, { key?, ok, err })` — the permanent raw SQLite escape hatch. The engine owns `app.db`; parameters are `null | number | string | Uint8Array | dbText(bytes) | boolean`. A read delivers 256-row/256-KiB pages then `done`, with one result capped at 8,192 rows or 8 MiB; crossing either total bound rejects the whole result, so use `LIMIT` and keyset pagination for larger collections. An exec commits its 164 statements as ONE transaction. SQL remains pathless: `ATTACH`/`DETACH`/`VACUUM INTO`, TEMP schema objects, and engine lifecycle PRAGMAs are denied. Query keys replace/cancel silently; duplicate transaction keys reject loudly. Error bytes are `constraint`, `busy`, `io_failed`, `corrupt`, `misuse`, `rejected`, or `cancelled`. Every page/terminal journals and replay never opens SQLite. Prefer declared queries; NS1420 nudges raw query literals toward them.
- `Cmd.readFile(path, { key?, ok, err })` — read a whole file. `ok` arm: one `Uint8Array` field with the content. `err` reasons: `not_found`, `io_failed`, `truncated` (the file exceeds the engine's 1 MiB read bound — a cut file never passes as whole), `rejected`. Paths are at most 1024 bytes.
- `Cmd.writeFile(path, bytes, { key?, ok, err })` — write a whole file (parent directories created, an existing file replaced whole; at most 1 MiB). `ok` arm: NO payload fields (`{ kind: "wrote" }`) — a successful write has nothing to report. `err` reasons: `io_failed`, `rejected`.
- `Cmd.appendFile(path, bytes, route)` appends one bounded payload; `Cmd.statFile(path, route)` returns `{ exists, size, mtimeMs }`. `Cmd.readFileStream(path, { key?, chunk, done, err })` delivers 256-KiB chunks then the total; reissuing or cancelling its key silently replaces/drops the read. Atomic exports open with `Cmd.writeFileStream(key, path, route)`, send one acknowledged `writeFileChunk` (≤1 MiB) at a time, then `writeFileClose`; a duplicate sink rejects, overlapping chunk/close routes `out_of_order`, and sink cancellation is loud. Stream chunks spill to the session blob store for byte-identical replay. Raw paths under this app's resolved data/config/cache/state/logs/temp roots need no grant; every external path requires the `filesystem` permission. The runtime resolves existing parents and symlinks before checking (an in-root symlink pointing out is external); NS1074 catches certainly-external literals.
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })` — a buffered HTTP(S) exchange. `ok` arm: exactly two fields, one `number` and one `Uint8Array` (`{ kind: "fetched", status: number, body: Uint8Array }`) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is still `ok` (an HTTP-level error is a delivered response). `err` reasons: `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `rejected`, and `truncated` (the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object: `url` bytes (≤ 2 KiB), `method` one of `"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"` (default GET), `headers` an inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes (`{ authorization: bearerToken(model.apiKey), "content-type": "application/json" }` — how a launch-supplied key rides an `Authorization` header; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030), `body` bytes (≤ 64 KiB), `timeoutMs` a positive integer literal (engine default when omitted).
- `Cmd.clipboardWrite(bytes)` — put bytes on the system clipboard, fire-and-forget: there is no routing, and a refused or over-bound write is dropped by design.
- `Cmd.clipboardRead({ key?, ok, err })` — read the clipboard. `ok` arm: one `Uint8Array` field with the text. `err` reasons: `failed` (no clipboard service, over-bound content, pasteboard error), `rejected`.
+1 -1
View File
@@ -12,7 +12,7 @@ Two rules resolve most failures:
- Operations on the outside world take a `std.Io` first (or right after the receiver). Get one from `init.io` in `main(init: std.process.Init)`, from `std.testing.io` in tests, or from `std.Io.Threaded` in code with no `Init` to thread through.
- Containers are unmanaged: initialize with `.empty` and pass the allocator to every mutating call.
In a UiApp, `update` never sees an `Io` — persistence, record and relational storage, secure credentials, subprocesses, HTTP, clocks, and timers go through the typed effects channel (`fx.persist`, `fx.storeSet`/`storeGet`/`storeDelete`/`storeScan`/`storeSetMany`, `fx.dbQuery`/`dbExec`, `fx.credentialsSet`/`credentialsGet`/`credentialsDelete`, `fx.readFile`, `fx.spawn`, `fx.fetch`, `fx.wallMs`, `fx.startTimer`; see `native skills get native-ui`). Raw `std.Io` belongs in `main`, tests, and standalone tools. Record-store apps declare `"store"`; relational apps declare `"sqlite"`; credentials require both the `"credentials"` capability and permission. Credential Msg arms carry `EffectCredentialsResult`; consume successful get bytes immediately and never retain them in Model. The engine owns database paths and app-scoped credential namespaces, while every result still returns as a Msg and crosses the replay journal (credential bytes are redacted). Use `TestHarness().createWithRelationalStore` for a hermetic in-memory relational database and `TestHarness().createWithCredentials` for a hermetic in-memory credential store. And because Zig analyzes lazily, a 0.15-ism can hide in code only one build path references: run BOTH `zig build` and `zig build test` before calling a change done.
In a UiApp, `update` never sees an `Io` — persistence, record and relational storage, secure credentials, subprocesses, HTTP, clocks, and timers go through the typed effects channel (`fx.persist`, `fx.storeSet`/`storeGet`/`storeDelete`/`storeScan`/`storeSetMany`, `fx.dbQuery`/`dbExec`, `fx.credentialsSet`/`credentialsGet`/`credentialsDelete`, `fx.readFile`/`appendFile`/`statFile`/streaming file verbs, `fx.spawn`, `fx.fetch`, `fx.wallMs`, `fx.startTimer`; see `native skills get native-ui`). Whole-file IO stays capped at 1 MiB; streamed reads deliver 256-KiB chunks, and streamed writes finalize atomically on close. External raw paths require the `filesystem` permission, while the app's six resolved directories are exempt after symlink-safe canonicalization. Raw `std.Io` belongs in `main`, tests, and standalone tools. Record-store apps declare `"store"`; relational apps declare `"sqlite"`; credentials require both the `"credentials"` capability and permission. Credential Msg arms carry `EffectCredentialsResult`; consume successful get bytes immediately and never retain them in Model. The engine owns database paths and app-scoped credential namespaces, while every result still returns as a Msg and crosses the replay journal (credential bytes are redacted). Use `TestHarness().createWithRelationalStore` for a hermetic in-memory relational database and `TestHarness().createWithCredentials` for a hermetic in-memory credential store. And because Zig analyzes lazily, a 0.15-ism can hide in code only one build path references: run BOTH `zig build` and `zig build test` before calling a change done.
## error: struct 'heap' has no member named 'GeneralPurposeAllocator' — allocators come from `main(init: std.process.Init)`
+29
View File
@@ -61,6 +61,10 @@ pub const RunOptions = struct {
record_store: ?native_sdk.RecordStoreBinding = null,
relational_store: ?native_sdk.RelationalStoreBinding = null,
credentials_enabled: bool = false,
file_access: ?native_sdk.FileAccessBinding = null,
/// Migration/testing switch. Shipping defaults to the final enforced
/// posture; set false for the preceding warn-only behavior.
file_access_enforce: bool = true,
relational_migrations: []const native_sdk.relational_store.Migration = &built_relational_migrations.migrations,
fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
@@ -508,6 +512,27 @@ pub fn runWithOptions(app: native_sdk.App, options: RunOptions, init: std.proces
var record_store_open = false;
var resolved_options = options;
resolved_options.credentials_enabled = manifestDeclaresCredentials();
var file_root_buffers: [6][1024]u8 = undefined;
var file_roots: [6][]const u8 = undefined;
const resolved_file_dirs = native_sdk.app_dirs.resolve(
.{ .name = options.bundle_id },
native_sdk.app_dirs.currentPlatform(),
native_sdk.debug.envFromMap(init.environ_map),
native_sdk.app_dirs.Buffers.fromArray(1024, &file_root_buffers),
) catch null;
var file_root_count: usize = 0;
if (resolved_file_dirs) |dirs| {
file_roots = .{ dirs.config, dirs.cache, dirs.data, dirs.state, dirs.logs, dirs.temp };
file_root_count = file_roots.len;
}
// Fail closed if app-dir resolution is unavailable: a filesystem grant
// still opens arbitrary paths, while an ungranted app gets no accidental
// unrestricted fallback merely because HOME/XDG data was malformed.
resolved_options.file_access = .{
.roots = file_roots[0..file_root_count],
.permitted = native_sdk.security.hasPermission(options.security.permissions, native_sdk.security.permission_filesystem),
.enforce = options.file_access_enforce,
};
if (comptime manifestDeclaresStore()) {
var data_dir_buffer: [512]u8 = undefined;
const app_data_dir = native_sdk.app_dirs.resolveOne(
@@ -616,6 +641,7 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
.record_store = options.record_store,
.relational_store = options.relational_store,
.credentials_enabled = options.credentials_enabled,
.file_access = options.file_access,
.environ = init.minimal.environ,
.session_recorder = session_recorder,
});
@@ -683,6 +709,7 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
.record_store = options.record_store,
.relational_store = options.relational_store,
.credentials_enabled = options.credentials_enabled,
.file_access = options.file_access,
.environ = init.minimal.environ,
.session_recorder = session_recorder,
});
@@ -747,6 +774,7 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
.record_store = options.record_store,
.relational_store = options.relational_store,
.credentials_enabled = options.credentials_enabled,
.file_access = options.file_access,
.environ = init.minimal.environ,
.session_recorder = session_recorder,
});
@@ -810,6 +838,7 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
.record_store = options.record_store,
.relational_store = options.relational_store,
.credentials_enabled = options.credentials_enabled,
.file_access = options.file_access,
.environ = init.minimal.environ,
.session_recorder = session_recorder,
});
+1
View File
@@ -16,6 +16,7 @@ comptime {
&relational_migrations.migrations,
mobile_build_options.credentials_capability,
mobile_build_options.credentials_permission,
mobile_build_options.filesystem_permission,
mobile_build_options.credentials_service,
));
}
+5
View File
@@ -1044,6 +1044,7 @@ const MobileCredentialHost = ui_host.UiAppHostWithStorageAndCredentials(
&.{},
true,
true,
false,
"dev.native-sdk.mobile-credentials",
);
const MobileCredentialApi = c_api.MobileCApi(MobileCredentialHost);
@@ -1210,6 +1211,10 @@ test "mobile store capability requires and binds the OS app-data root before sta
try std.testing.expectEqual(@as(c_int, 1), MobileStoreApi.native_sdk_app_set_data_root(app, path.ptr, path.len));
try std.testing.expect(self.record_store_open);
try std.testing.expect(self.embedded.runtime.options.record_store != null);
try std.testing.expectEqual(@as(usize, 6), self.embedded.runtime.options.file_access.?.roots.len);
for (self.embedded.runtime.options.file_access.?.roots) |root| {
try std.testing.expectEqualStrings(path, root);
}
MobileStoreApi.native_sdk_app_start(app);
try std.testing.expectEqualStrings("", std.mem.span(MobileStoreApi.native_sdk_app_last_error_name(app)));
+53 -2
View File
@@ -24,10 +24,12 @@
//! the ABI via `native_sdk_app_render_pixels`.
const std = @import("std");
const builtin = @import("builtin");
const app_manifest = @import("app_manifest");
const canvas = @import("canvas");
const runtime = @import("../runtime/root.zig");
const platform = @import("../platform/root.zig");
const app_dirs = @import("app_dirs");
const security = @import("../security/root.zig");
const types = @import("types.zig");
const host = @import("host.zig");
@@ -83,6 +85,7 @@ pub fn UiAppHostWithStorage(
relational_migrations,
false,
false,
false,
"dev.native_sdk.app",
);
}
@@ -96,6 +99,7 @@ pub fn UiAppHostWithStorageAndCredentials(
comptime relational_migrations: []const runtime.relational_store.Migration,
comptime credentials_enabled: bool,
comptime credentials_permitted: bool,
comptime filesystem_permitted: bool,
comptime credentials_service: []const u8,
) type {
const features: runtime.UiAppFeatures = if (@hasDecl(AppDef, "features")) AppDef.features else .{};
@@ -125,6 +129,8 @@ pub fn UiAppHostWithStorageAndCredentials(
asset_root_len: usize = 0,
asset_entry: [max_mobile_asset_entry_bytes]u8 = undefined,
asset_entry_len: usize = 0,
file_root_storage: [6][max_mobile_asset_root_bytes]u8 = undefined,
file_roots: [6][]const u8 = undefined,
automation_dir: [max_mobile_asset_root_bytes]u8 = undefined,
automation_dir_len: usize = 0,
automation_io: ?*std.Io.Threaded = null,
@@ -191,6 +197,8 @@ pub fn UiAppHostWithStorageAndCredentials(
self.asset_root_len = 0;
self.asset_entry = undefined;
self.asset_entry_len = 0;
self.file_root_storage = undefined;
self.file_roots = undefined;
self.automation_dir = undefined;
self.automation_dir_len = 0;
self.automation_io = null;
@@ -221,10 +229,21 @@ pub fn UiAppHostWithStorageAndCredentials(
// registers its OS credential service.
try host.setCredentialService(self, .{}, null);
self.embedded.runtime.options.credentials_enabled = credentials_enabled;
self.embedded.runtime.options.security.permissions = if (credentials_permitted)
self.embedded.runtime.options.security.permissions = if (credentials_permitted and filesystem_permitted)
&.{ security.permission_credentials, security.permission_filesystem }
else if (credentials_permitted)
&.{security.permission_credentials}
else if (filesystem_permitted)
&.{security.permission_filesystem}
else
&.{};
// Until the OS data root is installed, fail closed for ungranted
// raw paths. A filesystem grant is sufficient on its own.
self.embedded.runtime.options.file_access = .{
.roots = &.{},
.permitted = filesystem_permitted,
.enforce = true,
};
// The damage seam: capture pixel presents (chained through
// the null platform's recording present, so nonblank
// sampling keeps working), drop the packet presenters no
@@ -285,9 +304,41 @@ pub fn UiAppHostWithStorageAndCredentials(
/// Library/Application Support and Android passes files/, exactly the
/// `.data` directories resolved by `app_dirs` on those platforms.
pub fn setDataRoot(self: *Self, data_root: []const u8) !void {
if (comptime !record_store_enabled and !relational_store_enabled) return;
if (self.started) return error.AppAlreadyStarted;
if (data_root.len == 0 or data_root.len > max_mobile_asset_root_bytes) return error.InvalidStoreDataDir;
const platform_value = app_dirs.currentPlatform();
if (builtin.is_test and platform_value != .ios and platform_value != .android) {
for (&self.file_root_storage, 0..) |*storage, index| {
@memcpy(storage[0..data_root.len], data_root);
self.file_roots[index] = storage[0..data_root.len];
}
self.embedded.runtime.options.file_access = .{
.roots = &self.file_roots,
.permitted = filesystem_permitted,
.enforce = true,
};
} else {
const home = switch (platform_value) {
// iOS data_root is HOME/Library/Application Support.
.ios => std.fs.path.dirname(std.fs.path.dirname(data_root) orelse return error.InvalidStoreDataDir) orelse return error.InvalidStoreDataDir,
// Android data_root is HOME/files.
.android => std.fs.path.dirname(data_root) orelse return error.InvalidStoreDataDir,
else => return error.InvalidStoreDataDir,
};
const dir_buffers = app_dirs.Buffers.fromArray(max_mobile_asset_root_bytes, &self.file_root_storage);
const dirs = try app_dirs.resolve(
.{ .name = credentials_service },
platform_value,
.{ .home = home },
dir_buffers,
);
self.file_roots = .{ dirs.config, dirs.cache, dirs.data, dirs.state, dirs.logs, dirs.temp };
self.embedded.runtime.options.file_access = .{
.roots = &self.file_roots,
.permitted = filesystem_permitted,
.enforce = true,
};
}
if (comptime record_store_enabled) {
if (self.record_store_open) {
self.record_store.deinit();
+4
View File
@@ -44,8 +44,12 @@ pub const FetchResponseMode = runtime.FetchResponseMode;
pub const EffectResponse = runtime.EffectResponse;
pub const EffectFetchOutcome = runtime.EffectFetchOutcome;
pub const EffectFileOp = runtime.EffectFileOp;
pub const EffectFileEvent = runtime.EffectFileEvent;
pub const EffectFileOutcome = runtime.EffectFileOutcome;
pub const EffectFileResult = runtime.EffectFileResult;
pub const FileAccessBinding = runtime.FileAccessBinding;
pub const effect_file_stream_chunk_bytes = runtime.effect_file_stream_chunk_bytes;
pub const max_effect_file_streams = runtime.max_effect_file_streams;
pub const EffectCredentialsOperation = runtime.EffectCredentialsOperation;
pub const EffectCredentialsOutcome = runtime.EffectCredentialsOutcome;
pub const EffectCredentialsResult = runtime.EffectCredentialsResult;
+6
View File
@@ -501,6 +501,12 @@ pub const Options = struct {
/// checked separately; both gates must be open before core effects may
/// reach the platform keychain.
credentials_enabled: bool = false,
/// Raw-file policy resolved by the app runner. Six app-owned roots are
/// allowed without a grant; `filesystem` opens arbitrary paths.
/// Standard runners install the six resolved app-owned roots here. If a
/// custom runner omits the binding, UiApp fails closed with no exempt roots
/// while still honoring an explicit `filesystem` permission.
file_access: ?runtime_effects.FileAccessBinding = null,
js_window_api: bool = false,
/// Whether this build ships the embedded web layer. The app runner
/// sets it from the build graph's app.zon inference (declare-to-use:
+1191 -32
View File
File diff suppressed because it is too large Load Diff
+411
View File
@@ -437,6 +437,417 @@ test "real executor cuts over-bound reads with outcome truncated" {
);
}
test "append and stat are bounded one-shot file effects" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
var path_buffer: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/log/events.log", .{tmp.sub_path[0..]});
fx.appendFile(.{ .key = 1, .path = path, .bytes = "one", .on_result = Fx.fileMsg(.result) });
while (fx.takeMsg() == null) try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
fx.appendFile(.{ .key = 2, .path = path, .bytes = "-two", .on_result = Fx.fileMsg(.result) });
while (fx.takeMsg() == null) try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
fx.statFile(.{ .key = 3, .path = path, .on_result = Fx.fileMsg(.result) });
var stat_result: ?effects_mod.EffectFileResult = null;
while (stat_result == null) {
if (fx.takeMsg()) |msg| stat_result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expect(stat_result.?.exists);
try std.testing.expectEqual(@as(u64, 7), stat_result.?.total);
const bytes = try tmp.dir.readFileAlloc(io, "log/events.log", std.testing.allocator, .limited(32));
defer std.testing.allocator.free(bytes);
try std.testing.expectEqualStrings("one-two", bytes);
}
test "streaming file round-trip has no total-size cliff and finalizes atomically" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
var path_buffer: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/export.bin", .{tmp.sub_path[0..]});
const chunk = try std.testing.allocator.alloc(u8, effects_mod.effect_file_stream_chunk_bytes);
defer std.testing.allocator.free(chunk);
for (chunk, 0..) |*byte, index| byte.* = @truncate(index);
fx.writeFileStream(.{ .key = 44, .path = path, .on_result = Fx.fileMsg(.result) });
var write_result: ?effects_mod.EffectFileResult = null;
while (write_result == null) {
if (fx.takeMsg()) |msg| write_result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(@as(u64, 0), write_result.?.total);
for (0..5) |_| {
fx.writeFileChunk(.{ .key = 44, .bytes = chunk, .on_result = Fx.fileMsg(.result) });
write_result = null;
while (write_result == null) {
if (fx.takeMsg()) |msg| write_result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(@as(u64, 0), write_result.?.total);
}
fx.writeFileClose(.{ .key = 44, .on_result = Fx.fileMsg(.result) });
write_result = null;
while (write_result == null) {
if (fx.takeMsg()) |msg| write_result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(@as(u64, 0), write_result.?.total);
fx.readFileStream(.{ .key = 45, .path = path, .on_result = Fx.fileMsg(.result) });
var chunks: usize = 0;
var total: u64 = 0;
while (true) {
const msg = fx.takeMsg() orelse {
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
continue;
};
if (msg.result.event == .chunk) chunks += 1 else {
try std.testing.expectEqual(effects_mod.EffectFileEvent.done, msg.result.event);
total = msg.result.total;
break;
}
}
try std.testing.expectEqual(@as(usize, 5), chunks);
try std.testing.expectEqual(@as(u64, chunk.len * 5), total);
}
test "a rejected write-stream chunk can retry without losing its atomic sink" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "export.bin", .data = "previous generation" });
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
var fx = Fx.init(failing.allocator());
defer fx.deinit();
var path_buffer: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/export.bin", .{tmp.sub_path[0..]});
fx.writeFileStream(.{ .key = 55, .path = path, .on_result = Fx.fileMsg(.result) });
var result: ?effects_mod.EffectFileResult = null;
while (result == null) {
if (fx.takeMsg()) |msg| result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, result.?.outcome);
// Reject the engine-owned chunk copy after the atomic sink is open.
failing.fail_index = failing.alloc_index;
fx.writeFileChunk(.{ .key = 55, .bytes = "replacement", .on_result = Fx.fileMsg(.result) });
result = null;
while (result == null) {
if (fx.takeMsg()) |msg| result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, result.?.outcome);
const still_visible = try tmp.dir.readFileAlloc(io, "export.bin", std.testing.allocator, .limited(64));
defer std.testing.allocator.free(still_visible);
try std.testing.expectEqualStrings("previous generation", still_visible);
failing.fail_index = std.math.maxInt(usize);
fx.writeFileChunk(.{ .key = 55, .bytes = "replacement", .on_result = Fx.fileMsg(.result) });
result = null;
while (result == null) {
if (fx.takeMsg()) |msg| result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, result.?.outcome);
fx.writeFileClose(.{ .key = 55, .on_result = Fx.fileMsg(.result) });
result = null;
while (result == null) {
if (fx.takeMsg()) |msg| result = msg.result else try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, result.?.outcome);
const visible = try tmp.dir.readFileAlloc(io, "export.bin", std.testing.allocator, .limited(64));
defer std.testing.allocator.free(visible);
try std.testing.expectEqualStrings("replacement", visible);
}
test "an unclosed write stream never exposes a partial destination" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "export.bin", .data = "previous generation" });
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var path_buffer: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/export.bin", .{tmp.sub_path[0..]});
var fx = Fx.init(std.testing.allocator);
fx.writeFileStream(.{ .key = 54, .path = path, .on_result = Fx.fileMsg(.result) });
while (fx.takeMsg() == null) try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
fx.writeFileChunk(.{ .key = 54, .bytes = "partial replacement", .on_result = Fx.fileMsg(.result) });
while (fx.takeMsg() == null) try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
fx.deinit();
const visible = try tmp.dir.readFileAlloc(io, "export.bin", std.testing.allocator, .limited(64));
defer std.testing.allocator.free(visible);
try std.testing.expectEqualStrings("previous generation", visible);
}
test "stream sinks reject duplicate and out-of-order operations loudly" {
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.executor = .fake;
fx.writeFileStream(.{ .key = 9, .path = "out.bin", .on_result = Fx.fileMsg(.result) });
fx.writeFileStream(.{ .key = 9, .path = "other.bin", .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, fx.takeMsg().?.result.outcome);
try fx.acknowledgeFakeFileStreamOpen(9);
try fx.feedFileResultDetailed(.{ .key = 9, .op = .write_stream_open, .outcome = .ok });
_ = fx.takeMsg().?;
fx.writeFileChunk(.{ .key = 9, .bytes = "one", .on_result = Fx.fileMsg(.result) });
fx.writeFileChunk(.{ .key = 9, .bytes = "two", .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(effects_mod.EffectFileOutcome.out_of_order, fx.takeMsg().?.result.outcome);
}
test "loop-side stream protocol results are journaled as replay-regenerated" {
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
const Capture = struct {
var records: [8]effects_mod.EffectResultRecord = undefined;
var count: usize = 0;
fn note(_: *anyopaque, record: effects_mod.EffectResultRecord) void {
records[count] = record;
count += 1;
}
};
Capture.count = 0;
var context: u8 = 0;
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.executor = .fake;
fx.bindJournal(.{ .context = &context, .record_fn = Capture.note });
fx.writeFileChunk(.{ .key = 40, .bytes = "orphan", .on_result = Fx.fileMsg(.result) });
_ = fx.takeMsg().?;
fx.writeFileStream(.{ .key = 41, .path = "sink.bin", .on_result = Fx.fileMsg(.result) });
try fx.acknowledgeFakeFileStreamOpen(41);
try fx.feedFileResultDetailed(.{ .key = 41, .op = .write_stream_open, .outcome = .ok });
_ = fx.takeMsg().?;
fx.writeFileChunk(.{ .key = 41, .bytes = "one", .on_result = Fx.fileMsg(.result) });
fx.writeFileChunk(.{ .key = 41, .bytes = "two", .on_result = Fx.fileMsg(.result) });
_ = fx.takeMsg().?;
try fx.feedFileResultDetailed(.{ .key = 41, .op = .write_stream_chunk, .outcome = .ok, .total = 3 });
_ = fx.takeMsg().?;
const oversized = try std.testing.allocator.alloc(u8, effects_mod.max_effect_file_bytes + 1);
defer std.testing.allocator.free(oversized);
fx.writeFileChunk(.{ .key = 41, .bytes = oversized, .on_result = Fx.fileMsg(.result) });
_ = fx.takeMsg().?;
fx.writeFileClose(.{ .key = 42, .on_result = Fx.fileMsg(.result) });
_ = fx.takeMsg().?;
try std.testing.expectEqual(@as(usize, 6), Capture.count);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.sink_missing, Capture.records[0].file_outcome);
try std.testing.expect(Capture.records[0].file_rejected_admission);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, Capture.records[1].file_outcome);
try std.testing.expect(!Capture.records[1].file_rejected_admission);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.out_of_order, Capture.records[2].file_outcome);
try std.testing.expect(Capture.records[2].file_rejected_admission);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, Capture.records[3].file_outcome);
try std.testing.expect(!Capture.records[3].file_rejected_admission);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, Capture.records[4].file_outcome);
try std.testing.expect(Capture.records[4].file_rejected_admission);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.sink_missing, Capture.records[5].file_outcome);
try std.testing.expect(Capture.records[5].file_rejected_admission);
}
test "path-policy file rejections are journaled as executor truth" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
const Capture = struct {
var record: ?effects_mod.EffectResultRecord = null;
fn note(_: *anyopaque, value: effects_mod.EffectResultRecord) void {
record = value;
}
};
Capture.record = null;
var context: u8 = 0;
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.executor = .fake;
fx.bindJournal(.{ .context = &context, .record_fn = Capture.note });
fx.bindFileAccess(.{ .roots = &.{}, .permitted = false, .enforce = true });
var path_buffer: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/denied.bin", .{tmp.sub_path[0..]});
fx.readFile(.{ .key = 61, .path = path, .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, fx.takeMsg().?.result.outcome);
try std.testing.expect(Capture.record != null);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, Capture.record.?.file_outcome);
try std.testing.expect(!Capture.record.?.file_rejected_admission);
}
test "read streams replace and cancel silently while sinks cancel loudly" {
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.executor = .fake;
fx.readFileStream(.{ .key = 20, .path = "first.bin", .on_result = Fx.fileMsg(.result) });
fx.readFileStream(.{ .key = 20, .path = "replacement.bin", .on_result = Fx.fileMsg(.result) });
try fx.feedFileResultDetailed(.{ .key = 20, .op = .read_stream, .event = .chunk, .outcome = .ok, .bytes = "replacement", .total = 11 });
try std.testing.expectEqualStrings("replacement", fx.takeMsg().?.result.bytes);
fx.cancel(20);
try std.testing.expectEqual(@as(?TestMsg, null), fx.takeMsg());
try std.testing.expectError(error.EffectNotFound, fx.feedFileResultDetailed(.{ .key = 20, .op = .read_stream, .event = .done, .outcome = .ok, .total = 11 }));
fx.writeFileStream(.{ .key = 21, .path = "sink.bin", .on_result = Fx.fileMsg(.result) });
fx.cancel(21);
const cancelled = fx.takeMsg().?.result;
try std.testing.expectEqual(effects_mod.EffectFileOp.write_stream_open, cancelled.op);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.cancelled, cancelled.outcome);
}
test "replay cancellation keeps a fake sink parked for its recorded terminal" {
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.armReplay();
fx.writeFileStream(.{ .key = 22, .path = "sink.bin", .on_result = Fx.fileMsg(.result) });
fx.cancel(22);
try std.testing.expectEqual(@as(?TestMsg, null), fx.takeMsg());
try fx.feedFileResultDetailed(.{ .key = 22, .op = .write_stream_open, .outcome = .cancelled });
const cancelled = fx.takeMsg().?.result;
try std.testing.expectEqual(effects_mod.EffectFileOutcome.cancelled, cancelled.outcome);
}
test "disk capacity errors are closed and enum-named across whole and streaming writes" {
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.executor = .fake;
fx.failNextFileOperationForTest(error.NoSpaceLeft);
fx.writeFile(.{ .key = 31, .path = "whole.bin", .bytes = "x", .on_result = Fx.fileMsg(.result) });
var result = fx.takeMsg().?.result;
try std.testing.expectEqual(effects_mod.EffectFileOutcome.disk_full, result.outcome);
try std.testing.expectEqualStrings("disk_full", @tagName(result.outcome));
fx.failNextFileOperationForTest(error.DiskQuota);
fx.writeFileStream(.{ .key = 32, .path = "stream.bin", .on_result = Fx.fileMsg(.result) });
result = fx.takeMsg().?.result;
try std.testing.expectEqual(effects_mod.EffectFileOp.write_stream_open, result.op);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.disk_full, result.outcome);
fx.writeFileStream(.{ .key = 33, .path = "stream-chunk.bin", .on_result = Fx.fileMsg(.result) });
try fx.acknowledgeFakeFileStreamOpen(33);
try fx.feedFileResultDetailed(.{ .key = 33, .op = .write_stream_open, .outcome = .ok });
_ = fx.takeMsg().?;
fx.failNextFileOperationForTest(error.NoSpaceLeft);
fx.writeFileChunk(.{ .key = 33, .bytes = "chunk", .on_result = Fx.fileMsg(.result) });
result = fx.takeMsg().?.result;
try std.testing.expectEqual(effects_mod.EffectFileOp.write_stream_chunk, result.op);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.disk_full, result.outcome);
}
test "file access gating covers whole, append, stat, and stream verbs without consuming slots" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(std.testing.io, "app-data");
const TestMsg = union(enum) { result: effects_mod.EffectFileResult };
const Fx = effects_mod.Effects(TestMsg);
var fx = Fx.init(std.testing.allocator);
defer fx.deinit();
fx.executor = .fake;
var root_buffer: [256]u8 = undefined;
var inside_buffer: [256]u8 = undefined;
var outside_buffer: [256]u8 = undefined;
const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app-data", .{tmp.sub_path[0..]});
const inside = try std.fmt.bufPrint(&inside_buffer, "{s}/owned.bin", .{root});
const outside = try std.fmt.bufPrint(&outside_buffer, ".zig-cache/tmp/{s}/outside.bin", .{tmp.sub_path[0..]});
fx.bindFileAccess(.{ .roots = &.{root}, .permitted = false, .enforce = true });
fx.writeFile(.{ .key = 1, .path = inside, .bytes = "ok", .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(@as(usize, 1), fx.pendingFileCount());
fx.cancel(1);
_ = fx.takeMsg().?;
fx.readFile(.{ .key = 2, .path = outside, .on_result = Fx.fileMsg(.result) });
fx.writeFile(.{ .key = 3, .path = outside, .bytes = "x", .on_result = Fx.fileMsg(.result) });
fx.appendFile(.{ .key = 4, .path = outside, .bytes = "x", .on_result = Fx.fileMsg(.result) });
fx.statFile(.{ .key = 5, .path = outside, .on_result = Fx.fileMsg(.result) });
fx.readFileStream(.{ .key = 6, .path = outside, .on_result = Fx.fileMsg(.result) });
fx.writeFileStream(.{ .key = 7, .path = outside, .on_result = Fx.fileMsg(.result) });
const refused_ops = [_]effects_mod.EffectFileOp{ .read, .write, .append, .stat, .read_stream, .write_stream_open };
for (refused_ops) |expected_op| {
const refused = fx.takeMsg().?.result;
try std.testing.expectEqual(expected_op, refused.op);
try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, refused.outcome);
try std.testing.expectEqualStrings("rejected", @tagName(refused.outcome));
}
try std.testing.expectEqual(@as(usize, 0), fx.pendingFileCount());
var granted = Fx.init(std.testing.allocator);
defer granted.deinit();
granted.executor = .fake;
granted.bindFileAccess(.{ .roots = &.{}, .permitted = true, .enforce = true });
granted.statFile(.{ .key = 8, .path = outside, .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(@as(usize, 1), granted.pendingFileCount());
granted.readFile(.{ .key = 9, .path = inside, .on_result = Fx.fileMsg(.result) });
granted.readFileStream(.{ .key = 10, .path = inside, .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(@as(usize, 2), granted.pendingFileCount());
// Full matrix: both whole-file and stream families are admitted inside
// app roots regardless of the grant, and outside only with the grant.
var inside_ungranted = Fx.init(std.testing.allocator);
defer inside_ungranted.deinit();
inside_ungranted.executor = .fake;
inside_ungranted.bindFileAccess(.{ .roots = &.{root}, .permitted = false, .enforce = true });
inside_ungranted.readFile(.{ .key = 40, .path = inside, .on_result = Fx.fileMsg(.result) });
inside_ungranted.readFileStream(.{ .key = 41, .path = inside, .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(@as(usize, 1), inside_ungranted.pendingFileCount());
try inside_ungranted.feedFileResultDetailed(.{ .key = 41, .op = .read_stream, .event = .done, .outcome = .ok });
_ = inside_ungranted.takeMsg().?;
granted.readFileStream(.{ .key = 42, .path = outside, .on_result = Fx.fileMsg(.result) });
try granted.feedFileResultDetailed(.{ .key = 42, .op = .read_stream, .event = .done, .outcome = .ok });
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, granted.takeMsg().?.result.outcome);
var warn_only = Fx.init(std.testing.allocator);
defer warn_only.deinit();
warn_only.executor = .fake;
warn_only.bindFileAccess(.{ .roots = &.{root}, .permitted = false, .enforce = false });
warn_only.statFile(.{ .key = 43, .path = outside, .on_result = Fx.fileMsg(.result) });
try std.testing.expectEqual(@as(usize, 1), warn_only.pendingFileCount());
}
test "runtime binding defaults support one warn-only release before enforcement" {
const binding_warn: effects_mod.FileAccessBinding = .{ .roots = &.{}, .permitted = false, .enforce = false };
const binding_enforce: effects_mod.FileAccessBinding = .{ .roots = &.{}, .permitted = false, .enforce = true };
try std.testing.expectEqual(
@import("file_access.zig").Decision.warn,
@import("file_access.zig").decide(std.testing.allocator, std.testing.io, binding_warn, ".zig-cache/outside-file"),
);
try std.testing.expectEqual(
@import("file_access.zig").Decision.reject,
@import("file_access.zig").decide(std.testing.allocator, std.testing.io, binding_enforce, ".zig-cache/outside-file"),
);
}
// --------------------------------------------------- bounded teardown
/// Create a FIFO at `path` (POSIX-only; callers gate on the platform).
+354
View File
@@ -0,0 +1,354 @@
//! Runtime policy for raw file effects.
//!
//! A manifest `filesystem` grant allows any process-visible path. Without
//! that grant, raw file effects are confined to the six directories the
//! app-dir resolver owns for this app. The check canonicalizes the existing
//! target, or the deepest existing parent for a target that will be created,
//! before comparing roots. Consequently an in-root symlink that points out
//! is not an app-directory exemption.
const std = @import("std");
pub const max_roots: usize = 6;
pub const Binding = struct {
roots: []const []const u8 = &.{},
permitted: bool = false,
/// The migration seam. Shipping runners set this true; embedders may use
/// false for one warning release while still exercising normalization.
enforce: bool = true,
};
pub const Decision = enum { allow, warn, reject };
pub const Resolved = struct {
path: []u8,
decision: Decision,
/// For an app-directory exemption, the already-open parent directory
/// capability the worker must use instead of reopening `path`. `basename`
/// points into `path`. Holding this handle across the worker handoff closes
/// the canonicalize-then-open race: every parent was opened first and its
/// handle-identity path verified inside the selected root.
parent: ?std.Io.Dir = null,
basename: []const u8 = "",
/// A read/stat whose parent does not exist is authorized but absent. The
/// caller returns the operation's ordinary not-found shape without ever
/// reopening the unresolved pathname.
missing: bool = false,
pub fn deinit(self: *Resolved, allocator: std.mem.Allocator, io: std.Io) void {
if (self.parent) |parent| parent.close(io);
allocator.free(self.path);
self.* = undefined;
}
};
pub const ResolveOptions = struct {
create_parents: bool = false,
};
/// Normalize first, then decide authority over that exact spelling. Callers
/// perform I/O against `path`, never against the original request, so the
/// checked symlink/parent resolution is also the path handed to the worker.
pub fn resolve(
allocator: std.mem.Allocator,
io: std.Io,
binding: Binding,
requested_path: []const u8,
) !Resolved {
return resolveForOperation(allocator, io, binding, requested_path, .{});
}
pub fn resolveForOperation(
allocator: std.mem.Allocator,
io: std.Io,
binding: Binding,
requested_path: []const u8,
options: ResolveOptions,
) !Resolved {
if (requested_path.len == 0 or std.mem.indexOfScalar(u8, requested_path, 0) != null) return error.InvalidPath;
const canonical = try canonicalizeTarget(allocator, io, requested_path);
errdefer allocator.free(canonical);
if (binding.permitted) return .{ .path = canonical, .decision = .allow };
for (binding.roots) |root| {
if (root.len == 0) continue;
const canonical_root = canonicalizeTarget(allocator, io, root) catch continue;
defer allocator.free(canonical_root);
var root_dir = openCanonicalDir(io, canonical_root, options.create_parents) catch {
// A missing root can only authorize an absent read/stat. Compare
// canonical lexical spellings, then return the closed absence
// result; writes created the root above and never take this path.
if (!options.create_parents and pathIsWithin(canonical_root, canonical)) {
return .{ .path = canonical, .decision = .allow, .missing = true };
}
continue;
};
var root_real_storage: [std.Io.Dir.max_path_bytes]u8 = undefined;
const root_real_len = root_dir.realPath(io, &root_real_storage) catch {
root_dir.close(io);
continue;
};
const root_real = root_real_storage[0..root_real_len];
if (!pathIsWithin(root_real, canonical)) {
root_dir.close(io);
continue;
}
const relative = relativeToRoot(root_real, canonical) orelse {
root_dir.close(io);
continue;
};
const basename = std.fs.path.basename(relative);
if (basename.len == 0 or std.mem.eql(u8, basename, ".") or std.mem.eql(u8, basename, "..")) {
root_dir.close(io);
continue;
}
const parent_path = std.fs.path.dirname(relative) orelse "";
const parent = openVerifiedParent(io, root_dir, root_real, parent_path, options.create_parents) catch |err| switch (err) {
error.FileNotFound => return .{ .path = canonical, .decision = .allow, .missing = true },
else => continue,
};
const basename_offset = @intFromPtr(basename.ptr) - @intFromPtr(relative.ptr) +
(@intFromPtr(relative.ptr) - @intFromPtr(canonical.ptr));
return .{
.path = canonical,
.decision = .allow,
.parent = parent,
.basename = canonical[basename_offset .. basename_offset + basename.len],
};
}
return .{ .path = canonical, .decision = if (binding.enforce) .reject else .warn };
}
fn openCanonicalDir(io: std.Io, canonical_path: []const u8, create: bool) !std.Io.Dir {
var components = std.fs.path.componentIterator(canonical_path);
const root = components.root() orelse return error.BadPathName;
var current = try std.Io.Dir.openDirAbsolute(io, root, .{ .follow_symlinks = false });
errdefer current.close(io);
while (components.next()) |component| {
const next = current.openDir(io, component.name, .{ .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound => if (create) create_component: {
current.createDir(io, component.name, .default_dir) catch |create_err| switch (create_err) {
error.PathAlreadyExists => {},
else => return create_err,
};
break :create_component try current.openDir(io, component.name, .{ .follow_symlinks = false });
} else return error.FileNotFound,
else => return err,
};
current.close(io);
current = next;
}
return current;
}
pub fn decide(
allocator: std.mem.Allocator,
io: std.Io,
binding: Binding,
requested_path: []const u8,
) Decision {
const resolved = resolve(allocator, io, binding, requested_path) catch return .reject;
var owned = resolved;
defer owned.deinit(allocator, io);
return resolved.decision;
}
fn relativeToRoot(root: []const u8, target: []const u8) ?[]const u8 {
if (!pathIsWithin(root, target) or pathsEqual(root, target)) return null;
var at = root.len;
while (at < target.len and isSeparator(target[at])) at += 1;
return target[at..];
}
fn openVerifiedParent(
io: std.Io,
root_dir: std.Io.Dir,
canonical_root: []const u8,
parent_path: []const u8,
create_parents: bool,
) !std.Io.Dir {
var current = root_dir;
errdefer current.close(io);
var components = std.fs.path.componentIterator(parent_path);
while (components.next()) |component| {
var next = current.openDir(io, component.name, .{ .follow_symlinks = true }) catch |err| switch (err) {
error.FileNotFound => if (create_parents) create: {
current.createDir(io, component.name, .default_dir) catch |create_err| switch (create_err) {
error.PathAlreadyExists => {},
else => return create_err,
};
// A component we created must still be a directory, not a
// symlink substituted between create and open.
break :create try current.openDir(io, component.name, .{ .follow_symlinks = false });
} else return error.FileNotFound,
else => return err,
};
var next_real_storage: [std.Io.Dir.max_path_bytes]u8 = undefined;
const next_real_len = next.realPath(io, &next_real_storage) catch |err| {
next.close(io);
return err;
};
if (!pathIsWithin(canonical_root, next_real_storage[0..next_real_len])) {
next.close(io);
return error.AccessDenied;
}
current.close(io);
current = next;
}
return current;
}
/// Canonicalize the target when it exists. For a new target, walk upward to
/// the deepest existing parent, canonicalize that parent (resolving every
/// symlink in the chain), then append the missing lexical suffix. Root paths
/// are canonicalized independently so aliases such as macOS `/tmp` and
/// `/private/tmp` compare correctly.
pub fn isInsideAnyRoot(
allocator: std.mem.Allocator,
io: std.Io,
roots: []const []const u8,
requested_path: []const u8,
) bool {
if (requested_path.len == 0 or roots.len == 0 or std.mem.indexOfScalar(u8, requested_path, 0) != null) return false;
const canonical_target = canonicalizeTarget(allocator, io, requested_path) catch return false;
defer allocator.free(canonical_target);
return canonicalIsInsideAnyRoot(allocator, io, roots, canonical_target);
}
fn canonicalIsInsideAnyRoot(allocator: std.mem.Allocator, io: std.Io, roots: []const []const u8, canonical_target: []const u8) bool {
for (roots) |root| {
if (root.len == 0) continue;
const canonical_root = canonicalizeTarget(allocator, io, root) catch continue;
defer allocator.free(canonical_root);
if (pathIsWithin(canonical_root, canonical_target)) return true;
}
return false;
}
fn canonicalizeTarget(allocator: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 {
const cwd = std.Io.Dir.cwd();
if (cwd.realPathFileAlloc(io, path, allocator)) |resolved| {
defer allocator.free(resolved);
return allocator.dupe(u8, resolved);
} else |_| {}
// Preserve every missing component while searching for an existing
// ancestor. `std.fs.path.resolve` then normalizes `.`/`..` in the joined
// result, after the existing prefix has been resolved through symlinks.
var ancestor = path;
var suffix: std.ArrayList([]const u8) = .empty;
defer suffix.deinit(allocator);
while (true) {
const parent = std.fs.path.dirname(ancestor) orelse break;
const base = std.fs.path.basename(ancestor);
try suffix.append(allocator, base);
if (cwd.realPathFileAlloc(io, parent, allocator)) |resolved_parent| {
defer allocator.free(resolved_parent);
var parts: std.ArrayList([]const u8) = .empty;
defer parts.deinit(allocator);
try parts.append(allocator, resolved_parent);
var index = suffix.items.len;
while (index > 0) {
index -= 1;
try parts.append(allocator, suffix.items[index]);
}
return std.fs.path.resolve(allocator, parts.items);
} else |_| {}
if (std.mem.eql(u8, parent, ancestor)) break;
ancestor = parent;
}
// A completely missing relative tail still has one existing parent: cwd.
// Resolve it explicitly and append every requested component lexically.
if (!std.fs.path.isAbsolute(path)) {
const cwd_resolved = try cwd.realPathFileAlloc(io, ".", allocator);
defer allocator.free(cwd_resolved);
return std.fs.path.resolve(allocator, &.{ cwd_resolved, path });
}
return error.FileNotFound;
}
fn pathIsWithin(root: []const u8, target: []const u8) bool {
if (pathsEqual(root, target)) return true;
if (root.len == 0 or target.len <= root.len) return false;
if (!pathPrefixEqual(root, target[0..root.len])) return false;
return isSeparator(target[root.len]) or isSeparator(root[root.len - 1]);
}
fn pathsEqual(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
return pathPrefixEqual(a, b);
}
fn pathPrefixEqual(a: []const u8, b: []const u8) bool {
if (@import("builtin").os.tag != .windows) return std.mem.eql(u8, a, b);
return std.ascii.eqlIgnoreCase(a, b);
}
fn isSeparator(byte: u8) bool {
return byte == '/' or (@import("builtin").os.tag == .windows and byte == '\\');
}
test "canonical containment rejects lexical escapes and symlinks that point out" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(std.testing.io, "app/data");
try tmp.dir.createDirPath(std.testing.io, "outside");
var root_buffer: [256]u8 = undefined;
var inside_buffer: [256]u8 = undefined;
var escape_buffer: [256]u8 = undefined;
const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app/data", .{tmp.sub_path[0..]});
const inside = try std.fmt.bufPrint(&inside_buffer, "{s}/nested/new.bin", .{root});
const escaped = try std.fmt.bufPrint(&escape_buffer, "{s}/../../outside/new.bin", .{root});
try std.testing.expect(isInsideAnyRoot(std.testing.allocator, std.testing.io, &.{root}, inside));
try std.testing.expect(!isInsideAnyRoot(std.testing.allocator, std.testing.io, &.{root}, escaped));
if (@import("builtin").os.tag != .windows) {
try tmp.dir.symLink(std.testing.io, "../../outside", "app/data/link", .{ .is_directory = true });
var symlink_buffer: [256]u8 = undefined;
const through_symlink = try std.fmt.bufPrint(&symlink_buffer, "{s}/link/new.bin", .{root});
try std.testing.expect(!isInsideAnyRoot(std.testing.allocator, std.testing.io, &.{root}, through_symlink));
}
}
test "an authorized request retains the opened parent capability" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(std.testing.io, "app/data/nested");
var root_buffer: [256]u8 = undefined;
var target_buffer: [256]u8 = undefined;
const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app/data", .{tmp.sub_path[0..]});
const target = try std.fmt.bufPrint(&target_buffer, "{s}/nested/file.bin", .{root});
var resolved = try resolveForOperation(std.testing.allocator, std.testing.io, .{ .roots = &.{root} }, target, .{ .create_parents = true });
defer resolved.deinit(std.testing.allocator, std.testing.io);
try std.testing.expectEqual(Decision.allow, resolved.decision);
try std.testing.expect(resolved.parent != null);
try std.testing.expectEqualStrings("file.bin", resolved.basename);
}
test "a retained parent capability ignores a later pathname symlink swap" {
if (@import("builtin").os.tag == .windows) return error.SkipZigTest;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(std.testing.io, "app/data/nested");
try tmp.dir.createDirPath(std.testing.io, "outside");
var root_buffer: [256]u8 = undefined;
var target_buffer: [256]u8 = undefined;
const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app/data", .{tmp.sub_path[0..]});
const target = try std.fmt.bufPrint(&target_buffer, "{s}/nested/file.bin", .{root});
var resolved = try resolveForOperation(std.testing.allocator, std.testing.io, .{ .roots = &.{root} }, target, .{ .create_parents = true });
defer resolved.deinit(std.testing.allocator, std.testing.io);
try tmp.dir.rename("app/data/nested", tmp.dir, "app/data/retained", std.testing.io);
try tmp.dir.symLink(std.testing.io, "../../outside", "app/data/nested", .{ .is_directory = true });
try resolved.parent.?.writeFile(std.testing.io, .{ .sub_path = resolved.basename, .data = "safe" });
const retained = try tmp.dir.readFileAlloc(std.testing.io, "app/data/retained/file.bin", std.testing.allocator, .limited(16));
defer std.testing.allocator.free(retained);
try std.testing.expectEqualStrings("safe", retained);
try std.testing.expectError(error.FileNotFound, tmp.dir.openFile(std.testing.io, "outside/file.bin", .{}));
}
+28
View File
@@ -0,0 +1,28 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const Msg = union(enum) { file: native_sdk.EffectFileResult };
const Fx = native_sdk.Effects(Msg);
fn waitResult(fx: *Fx, io: std.Io) !native_sdk.EffectFileResult {
while (true) {
if (fx.takeMsg()) |msg| return msg.file;
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
}
}
pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
if (args.len != 3) return error.InvalidArguments;
const destination = args[1];
const ready = args[2];
var fx = Fx.init(init.gpa);
// Deliberately no deinit: the parent kills this process to model a hard
// crash after the temporary contains bytes but before atomic replace.
fx.writeFileStream(.{ .key = 1, .path = destination, .on_result = Fx.fileMsg(.file) });
if ((try waitResult(&fx, init.io)).outcome != .ok) return error.OpenFailed;
fx.writeFileChunk(.{ .key = 1, .bytes = "partial replacement that must never become visible", .on_result = Fx.fileMsg(.file) });
if ((try waitResult(&fx, init.io)).outcome != .ok) return error.ChunkFailed;
try std.Io.Dir.cwd().writeFile(init.io, .{ .sub_path = ready, .data = "ready" });
while (true) try std.Io.sleep(init.io, std.Io.Duration.fromSeconds(60), .awake);
}
+37
View File
@@ -0,0 +1,37 @@
const std = @import("std");
const crash_options = @import("file_crash_options");
test "hard process death before streamed close leaves the installed file untouched" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "export.bin", .data = "previous complete generation" });
var destination_storage: [std.Io.Dir.max_path_bytes]u8 = undefined;
var ready_storage: [std.Io.Dir.max_path_bytes]u8 = undefined;
const destination = try std.fmt.bufPrint(&destination_storage, ".zig-cache/tmp/{s}/export.bin", .{tmp.sub_path[0..]});
const ready = try std.fmt.bufPrint(&ready_storage, ".zig-cache/tmp/{s}/ready", .{tmp.sub_path[0..]});
var child = try std.process.spawn(io, .{
.argv = &.{ crash_options.helper_executable, destination, ready },
.stdin = .ignore,
.stdout = .ignore,
.stderr = .inherit,
});
defer child.kill(io);
var waited_ms: usize = 0;
while (waited_ms < 20_000) : (waited_ms += 10) {
if (tmp.dir.openFile(io, "ready", .{})) |file_value| {
var file = file_value;
file.close(io);
break;
} else |_| {}
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(10), .awake);
}
if (waited_ms == 20_000) return error.TestTimedOut;
child.kill(io);
const visible = try tmp.dir.readFileAlloc(io, "export.bin", std.testing.allocator, .limited(128));
defer std.testing.allocator.free(visible);
try std.testing.expectEqualStrings("previous complete generation", visible);
}
+5
View File
@@ -80,8 +80,12 @@ pub const max_effect_fetch_payload_bytes = runtime_effects.max_effect_fetch_payl
pub const max_effect_body_bytes = runtime_effects.max_effect_body_bytes;
pub const default_effect_fetch_timeout_ms = runtime_effects.default_effect_fetch_timeout_ms;
pub const EffectFileOp = runtime_effects.EffectFileOp;
pub const EffectFileEvent = runtime_effects.EffectFileEvent;
pub const EffectFileOutcome = runtime_effects.EffectFileOutcome;
pub const EffectFileResult = runtime_effects.EffectFileResult;
pub const FileAccessBinding = runtime_effects.FileAccessBinding;
pub const effect_file_stream_chunk_bytes = runtime_effects.effect_file_stream_chunk_bytes;
pub const max_effect_file_streams = runtime_effects.max_effect_file_streams;
pub const EffectCredentialsOperation = runtime_effects.EffectCredentialsOperation;
pub const EffectCredentialsOutcome = runtime_effects.EffectCredentialsOutcome;
pub const EffectCredentialsResult = runtime_effects.EffectCredentialsResult;
@@ -185,6 +189,7 @@ pub const ts_core_spawn_key_base = runtime_ts_core_host.spawn_key_base;
pub const ts_core_audio_key_base = runtime_ts_core_host.audio_key_base;
pub const ts_core_video_key_base = runtime_ts_core_host.video_key_base;
pub const ts_core_db_key_base = runtime_ts_core_host.db_key_base;
pub const ts_core_file_stream_key_base = runtime_ts_core_host.file_stream_key_base;
const runtime_ts_ui_app = @import("ts_ui_app.zig");
pub const TsUiApp = runtime_ts_ui_app.TsUiApp;
+4 -1
View File
@@ -39,7 +39,10 @@ pub const hash_len: usize = runtime_effects.effect_image_blob_hash_len;
/// The largest payload one blob may hold — the largest journaled effect
/// payload that goes out of line (an image load's encoded source).
pub const max_blob_bytes: usize = @max(runtime_effects.max_effect_image_bytes, runtime_effects.max_effect_persist_snapshot_bytes);
pub const max_blob_bytes: usize = @max(
runtime_effects.effect_file_stream_chunk_bytes,
@max(runtime_effects.max_effect_image_bytes, runtime_effects.max_effect_persist_snapshot_bytes),
);
pub const BlobHash = [hash_len]u8;
+14
View File
@@ -1040,7 +1040,12 @@ pub fn encodeEffect(record: EffectResultRecord, buffer: []u8) JournalError![]con
try cursor.writeInt(u16, record.status);
try cursor.writeEnum(record.fetch_outcome);
try cursor.writeEnum(record.file_op);
try cursor.writeEnum(record.file_event);
try cursor.writeEnum(record.file_outcome);
try cursor.writeInt(u64, record.file_total);
try cursor.writeInt(i64, record.file_mtime_ms);
try cursor.writeBool(record.file_exists);
try cursor.writeBool(record.file_rejected_admission);
try cursor.writeEnum(record.clipboard_op);
try cursor.writeEnum(record.clipboard_outcome);
try cursor.writeInt(u64, record.timer_timestamp_ns);
@@ -1051,6 +1056,8 @@ pub fn encodeEffect(record: EffectResultRecord, buffer: []u8) JournalError![]con
try cursor.writeInt(u64, record.audio_duration_ms);
try cursor.writeBool(record.audio_playing);
try cursor.writeBool(record.audio_buffering);
try cursor.writeBytes(&record.file_blob_hash);
try cursor.writeInt(u64, record.file_blob_len);
try cursor.writeBytes(&record.audio_bands);
// v7: image terminals — outcome, decoded dimensions, and the blob
// store content address of the journaled source bytes.
@@ -1126,7 +1133,12 @@ pub fn decodeEffect(bytes: []const u8) JournalError!EffectResultRecord {
.status = try cursor.readInt(u16),
.fetch_outcome = try cursor.readEnum(runtime_effects.EffectFetchOutcome),
.file_op = try cursor.readEnum(runtime_effects.EffectFileOp),
.file_event = try cursor.readEnum(runtime_effects.EffectFileEvent),
.file_outcome = try cursor.readEnum(runtime_effects.EffectFileOutcome),
.file_total = try cursor.readInt(u64),
.file_mtime_ms = try cursor.readInt(i64),
.file_exists = try cursor.readBool(),
.file_rejected_admission = try cursor.readBool(),
.clipboard_op = try cursor.readEnum(runtime_effects.EffectClipboardOp),
.clipboard_outcome = try cursor.readEnum(runtime_effects.EffectClipboardOutcome),
.timer_timestamp_ns = try cursor.readInt(u64),
@@ -1138,6 +1150,8 @@ pub fn decodeEffect(bytes: []const u8) JournalError!EffectResultRecord {
.audio_playing = try cursor.readBool(),
.audio_buffering = try cursor.readBool(),
};
@memcpy(&record.file_blob_hash, try cursor.readBytes(record.file_blob_hash.len));
record.file_blob_len = try cursor.readInt(u64);
@memcpy(&record.audio_bands, try cursor.readBytes(record.audio_bands.len));
// v7: image terminals.
record.image_outcome = try cursor.readEnum(runtime_effects.EffectImageOutcome);
+38
View File
@@ -241,6 +241,16 @@ pub const SessionRecorder = struct {
journaled.credentials_secret_len = record.payload.len;
}
}
if (record.kind == .file and record.file_event == .chunk and record.payload.len > 0) {
const blob_sink = self.blob_sink orelse {
return self.fail("a streamed file chunk needs the session blob store, and none is bound - wire SessionRecorder.blob_sink");
};
const hash = session_blobs.hashBytes(record.payload);
blob_sink.write_fn(blob_sink.context, hash, record.payload) catch |err| return self.fail(@errorName(err));
journaled.file_blob_hash = hash;
journaled.file_blob_len = record.payload.len;
journaled.payload = "";
}
if (record.kind == .image and record.payload.len > 0) {
const blob_sink = self.blob_sink orelse {
return self.fail("an image effect result needs the session blob store, and none is bound - wire SessionRecorder.blob_sink (the app runner creates blobs/ beside the journal)");
@@ -458,6 +468,34 @@ test "recorder moves model restore snapshots into the session blob store" {
try testing.expectEqualStrings("canonical model", restored);
}
test "recorder moves streamed file chunks into the session blob store" {
var buffer_sink = BufferSink{};
var blobs = session_blobs.MemoryBlobStore.init(testing.allocator);
defer blobs.deinit();
const recorder = try testing.allocator.create(SessionRecorder);
defer testing.allocator.destroy(recorder);
recorder.* = SessionRecorder.init(buffer_sink.sink());
recorder.blob_sink = blobs.sink();
recorder.begin(.{ .platform_name = "test", .app_name = "file-stream" });
recorder.recordEffect(.{
.kind = .file,
.key = 91,
.payload = "streamed bytes",
.file_op = .read_stream,
.file_event = .chunk,
.file_outcome = .ok,
.file_total = 14,
});
recorder.finish();
var reader = try journal.Reader.init(buffer_sink.bytes());
_ = (try reader.next()).?;
const effect = (try reader.next()).?.effect;
try testing.expectEqual(@as(usize, 0), effect.payload.len);
try testing.expectEqual(@as(u64, 14), effect.file_blob_len);
var scratch: [32]u8 = undefined;
try testing.expectEqualStrings("streamed bytes", try blobs.read(effect.file_blob_hash, &scratch));
}
test "recorder redacts credential bytes from journal and blob storage" {
const secret = "storage-tier-four-live-token";
const set_secret = "storage-tier-four-set-token";
+97 -7
View File
@@ -178,6 +178,13 @@ pub fn replaySession(
},
.effect => |effect_record| {
var effect = effect_record;
if (effect.kind == .file and fileRecordDamaged(effect)) {
std.debug.print(
"replay refused after event {d}: file record for key {d} has an invalid stream/stat payload shape\n",
.{ report.events_replayed, effect.key },
);
return error.ReplayDamagedRecord;
}
// A `.loaded` image record ALWAYS names source bytes:
// the recorder journals `.loaded` only after those
// exact bytes decoded and registered (a failed decode
@@ -362,6 +369,16 @@ pub fn replaySession(
};
effect.payload = bytes;
}
if (effect.kind == .file and effect.file_blob_len > 0) {
const bytes = resolveBlob(effect.file_blob_hash, effect.file_blob_len, runtime_effects.effect_file_stream_chunk_bytes, options.blobs, &blob_scratch) catch |err| {
std.debug.print(
"replay refused after event {d}: file stream for key {d} references blob {s} ({d} bytes) that could not be resolved ({s})\n",
.{ report.events_replayed, effect.key, session_blobs.hexName(effect.file_blob_hash), effect.file_blob_len, @errorName(err) },
);
return error.ReplayMissingBlob;
};
effect.payload = bytes;
}
if (effect.kind == .image and effect.image_blob_len > 0) {
const bytes = resolveBlob(effect.image_blob_hash, effect.image_blob_len, runtime_effects.max_effect_image_bytes, options.blobs, &blob_scratch) catch |err| {
std.debug.print(
@@ -592,6 +609,25 @@ fn dbRecordDamaged(record: journal.EffectResultRecord) bool {
};
}
fn fileRecordDamaged(record: journal.EffectResultRecord) bool {
if (record.file_rejected_admission) switch (record.file_outcome) {
.rejected => {},
.sink_missing, .out_of_order => if (record.file_op != .write_stream_chunk and record.file_op != .write_stream_close) return true,
else => return true,
};
if (record.file_blob_len > runtime_effects.effect_file_stream_chunk_bytes) return true;
if (record.file_event == .chunk) {
return record.file_op != .read_stream or record.file_outcome != .ok or
record.payload.len != 0 or record.file_blob_len == 0;
}
if (record.file_blob_len != 0) return true;
if (record.file_event == .done) {
return record.file_op != .read_stream or record.file_outcome != .ok or record.payload.len != 0;
}
if (record.file_op == .stat and record.file_outcome == .ok) return record.payload.len != 0;
return record.file_total != 0 or record.file_mtime_ms != 0 or record.file_exists;
}
fn credentialsRecordDamaged(record: journal.EffectResultRecord) bool {
if (record.payload.len != 0 or record.exit_reason != .exited) return true;
const redacted_get = record.credentials_operation == .get and record.credentials_outcome == .ok;
@@ -731,15 +767,13 @@ fn effectRegeneratesUnderReplay(record: journal.EffectResultRecord) bool {
return switch (record.kind) {
.timer => true,
.exit => record.exit_reason == .rejected,
// Only DETERMINISTIC admission refusals regenerate — marked by
// the provenance bit the recorder set (`truncated`, unused for
// pty otherwise). Executor-truth terminals — start failures,
// the platform-unsupported rejection, output, and real exits —
// are external inputs and must be fed, even when their reason
// is `.rejected`.
// Only DETERMINISTIC admission/protocol results regenerate — marked
// by each effect family's provenance bit. Executor-truth terminals —
// start failures, platform refusals, output, and real exits — are
// external inputs and must be fed, even when their reason is rejected.
.pty => record.pty_kind == .exit and record.truncated,
.response => record.fetch_outcome == .rejected,
.file => record.file_outcome == .rejected,
.file => record.file_rejected_admission,
.clipboard => record.clipboard_outcome == .rejected,
// Audio rejections are loop-side validation (path bounds) that
// refuses again; everything else — loaded acknowledgments,
@@ -791,6 +825,62 @@ fn effectRegeneratesUnderReplay(record: journal.EffectResultRecord) bool {
};
}
test "only admission-tagged file results regenerate" {
try std.testing.expect(effectRegeneratesUnderReplay(.{
.kind = .file,
.key = 1,
.file_outcome = .rejected,
.file_rejected_admission = true,
}));
try std.testing.expect(!fileRecordDamaged(.{
.kind = .file,
.key = 2,
.file_op = .write_stream_chunk,
.file_outcome = .sink_missing,
.file_rejected_admission = true,
}));
try std.testing.expect(fileRecordDamaged(.{
.kind = .file,
.key = 2,
.file_op = .write_stream_chunk,
.file_outcome = .ok,
.file_rejected_admission = true,
}));
try std.testing.expect(!effectRegeneratesUnderReplay(.{
.kind = .file,
.key = 1,
.file_op = .read_stream,
.file_outcome = .rejected,
}));
try std.testing.expect(effectRegeneratesUnderReplay(.{
.kind = .file,
.key = 2,
.file_op = .write_stream_chunk,
.file_outcome = .sink_missing,
.file_rejected_admission = true,
}));
try std.testing.expect(effectRegeneratesUnderReplay(.{
.kind = .file,
.key = 3,
.file_op = .write_stream_close,
.file_outcome = .out_of_order,
.file_rejected_admission = true,
}));
try std.testing.expect(!fileRecordDamaged(.{
.kind = .file,
.key = 4,
.file_op = .write_stream_chunk,
.file_outcome = .ok,
}));
try std.testing.expect(fileRecordDamaged(.{
.kind = .file,
.key = 4,
.file_op = .write_stream_chunk,
.file_outcome = .ok,
.file_total = 1,
}));
}
/// Re-render a journaled screenshot mark through the same deterministic
/// reference renderer the automation `screenshot` verb used at record
/// time, and hash the PNG.
+81
View File
@@ -1448,6 +1448,8 @@ const ImageSessionModel = struct {
exits: u32 = 0,
exits_rejected: u32 = 0,
files: u32 = 0,
stream_bytes: u64 = 0,
stream_done: bool = false,
/// Armed by `.arm_fetch_chain` / `.arm_file_chain`: the NEXT
/// terminal of that family answers by reissuing the SAME key from
/// inside its own update (one-shot) — the poll/reload idiom whose
@@ -1475,6 +1477,7 @@ const ImageSessionMsg = union(enum) {
fetch_cover,
spawn_cover,
read_note,
stream_note,
line: effects_mod.EffectLine,
image: effects_mod.EffectImageResult,
response: effects_mod.EffectResponse,
@@ -1519,6 +1522,7 @@ fn imageSessionUpdate(model: *ImageSessionModel, msg: ImageSessionMsg, fx: *Imag
// A file read on its own key (26 — never colliding with the
// image/fetch/spawn probes above).
.read_note => fx.readFile(.{ .key = 26, .path = "notes/session.txt", .on_result = ImageSessionApp.Effects.fileMsg(.file) }),
.stream_note => fx.readFileStream(.{ .key = 27, .path = "notes/large.bin", .on_result = ImageSessionApp.Effects.fileMsg(.file) }),
// Aimed at the cover's id: against a running load this marks
// it cancelled; against a staged start-failure rejection (no
// slot exists) it is a no-op and the rejection stands.
@@ -1547,6 +1551,10 @@ fn imageSessionUpdate(model: *ImageSessionModel, msg: ImageSessionMsg, fx: *Imag
},
.file => |result| {
model.files += 1;
if (result.op == .read_stream) {
if (result.event == .chunk) model.stream_bytes += result.bytes.len;
if (result.event == .done) model.stream_done = true;
}
// The armed reload idiom, the fetch chain's file twin.
if (model.chain_next_file and result.key == 26) {
model.chain_next_file = false;
@@ -1590,6 +1598,7 @@ fn imageSessionView(ui: *ImageSessionApp.Ui, model: *const ImageSessionModel) Im
ui.text(.{}, ui.fmt("{d} lines, {d} before image", .{ model.lines_seen, model.lines_before_image })),
ui.text(.{}, ui.fmt("{d}/{d} responses, {d}/{d} exits rejected", .{ model.responses_rejected, model.responses, model.exits_rejected, model.exits })),
ui.text(.{}, ui.fmt("{d} files", .{model.files})),
ui.text(.{}, ui.fmt("stream {d} done {}", .{ model.stream_bytes, model.stream_done })),
});
}
@@ -1604,6 +1613,7 @@ fn imageSessionCommand(name: []const u8) ?ImageSessionMsg {
if (std.mem.eql(u8, name, "image.fetch-chain")) return .arm_fetch_chain;
if (std.mem.eql(u8, name, "image.file-chain")) return .arm_file_chain;
if (std.mem.eql(u8, name, "image.read-note")) return .read_note;
if (std.mem.eql(u8, name, "image.stream-note")) return .stream_note;
if (std.mem.eql(u8, name, "image.cancel")) return .cancel_cover;
if (std.mem.eql(u8, name, "image.chatty")) return .start_chatty;
if (std.mem.eql(u8, name, "image.fetch-cover")) return .fetch_cover;
@@ -2522,6 +2532,77 @@ test "a same-key retry from a fetch or file terminal handler is accepted live an
try std.testing.expectEqual(recorded.fingerprint, harness.runtime.sessionStateFingerprint());
}
test "a multi-megabyte file stream records through blobs and replays byte-identically" {
const gpa = std.testing.allocator;
const buffer = try std.heap.page_allocator.create(JournalBuffer);
defer std.heap.page_allocator.destroy(buffer);
buffer.len = 0;
var store = session_blobs.MemoryBlobStore.init(gpa);
defer store.deinit();
const recorder = try std.heap.page_allocator.create(session_record.SessionRecorder);
defer std.heap.page_allocator.destroy(recorder);
recorder.* = session_record.SessionRecorder.init(buffer.sink());
recorder.blob_sink = store.sink();
recorder.begin(.{ .platform_name = "test", .app_name = "file-stream-session", .window_width = 400, .window_height = 300 });
const harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) });
defer harness.destroy(gpa);
harness.null_platform.gpu_surfaces = true;
harness.runtime.options.session_recorder = recorder;
const app_state = try gpa.create(ImageSessionApp);
defer gpa.destroy(app_state);
app_state.* = ImageSessionApp.init(std.heap.page_allocator, .{}, imageSessionOptions());
defer app_state.deinit();
app_state.effects.executor = .fake;
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = image_canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
} });
try harness.runtime.dispatchPlatformEvent(app, .{ .menu_command = .{ .name = "image.stream-note", .window_id = 1 } });
const stream_key: u64 = 27;
const chunk = try gpa.alloc(u8, effects_mod.effect_file_stream_chunk_bytes);
defer gpa.free(chunk);
var total: u64 = 0;
for (0..5) |chunk_index| {
for (chunk, 0..) |*byte, index| byte.* = @truncate(index + chunk_index * 17);
total += chunk.len;
try app_state.effects.feedFileResultDetailed(.{ .key = stream_key, .op = .read_stream, .event = .chunk, .outcome = .ok, .bytes = chunk, .total = total });
try harness.runtime.dispatchPlatformEvent(app, .wake);
}
try std.testing.expect(total > effects_mod.max_effect_file_bytes);
try app_state.effects.feedFileResultDetailed(.{ .key = stream_key, .op = .read_stream, .event = .done, .outcome = .ok, .total = total });
try harness.runtime.dispatchPlatformEvent(app, .wake);
try harness.runtime.dispatchPlatformEvent(app, .frame_requested);
recorder.finish();
try std.testing.expect(!recorder.failed);
try std.testing.expectEqual(@as(usize, 5), store.count);
const recorded_model = app_state.model;
const recorded_fingerprint = harness.runtime.sessionStateFingerprint();
const replay_harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) });
defer replay_harness.destroy(gpa);
replay_harness.null_platform.gpu_surfaces = true;
const replay_app = try gpa.create(ImageSessionApp);
defer gpa.destroy(replay_app);
replay_app.* = ImageSessionApp.init(std.heap.page_allocator, .{}, imageSessionOptions());
defer replay_app.deinit();
const report = try session_replay.replaySession(&replay_harness.runtime, replay_app.app(), buffer.journalBytes(), .{
.verify = true,
.require_same_platform = false,
.blobs = store.source(),
});
try std.testing.expect(report.ok());
try std.testing.expectEqual(@as(u64, 6), report.effects_fed);
try std.testing.expectEqualDeep(recorded_model, replay_app.model);
try std.testing.expectEqual(recorded_fingerprint, replay_harness.runtime.sessionStateFingerprint());
}
/// Record the queue-saturation reference session: a chatty spawn and
/// an image load whose results journal as ONE unbroken run — 64 line
/// records (the completion queue's whole capacity) followed by the
+257 -11
View File
@@ -1,6 +1,6 @@
//! The native host consumer for compiled TypeScript app cores: bridges
//! the versioned command/subscription wire format a compiled core
//! emits (`cmd_format_version` 4) onto the real effect engine
//! emits (`cmd_format_version` 5) onto the real effect engine
//! (`effects.zig`). The TypeScript tier's core module is a pure
//! Model/Msg/update core whose effects are INERT BYTES — this module is
//! the one place those bytes become engine calls, so the entire
@@ -436,6 +436,9 @@ pub const pty_key_base: u64 = 0x5453_5054_0000_0000;
/// its own engine table, so these never consume the general request slots.
pub const db_key_base: u64 = 0x5453_4442_0000_0000;
/// Dedicated raw-file stream key namespace ("TSFS").
pub const file_stream_key_base: u64 = 0x5453_4653_0000_0000;
/// The spawn wire record's "no line routing" tag sentinel (the wire
/// format's shared constant).
pub const spawn_no_line_tag: u8 = 0xFF;
@@ -574,6 +577,21 @@ pub fn TsCoreHost(comptime core: type) type {
}
};
const FileStreamEntry = struct {
used: bool = false,
sink: bool = false,
busy: bool = false,
key_len: usize = 0,
key: [max_wire_key_bytes]u8 = undefined,
chunk_tag: u8 = 0,
done_tag: u8 = 0,
err_tag: u8 = 0,
cancelling: bool = false,
fn wireKey(entry: *const FileStreamEntry) []const u8 {
return entry.key[0..entry.key_len];
}
};
/// The single audio stream entry (one player is the whole
/// engine surface). Non-retiring: audio_ctl `stop` closes it, a
/// new audio_play re-keys and re-routes it in place.
@@ -694,6 +712,7 @@ pub fn TsCoreHost(comptime core: type) type {
var effects_table: [runtime_effects.max_effects]EffectEntry = @splat(.{});
var delays: [runtime_effects.max_effect_timers]DelayEntry = @splat(.{});
var streams: [runtime_effects.max_effects]StreamEntry = @splat(.{});
var file_streams: [runtime_effects.max_effect_file_streams]FileStreamEntry = @splat(.{});
var audio_entry: AudioEntry = .{};
var video_entry: VideoEntry = .{};
var images: [runtime_effects.max_effects]ImageEntry = @splat(.{});
@@ -1018,8 +1037,9 @@ pub fn TsCoreHost(comptime core: type) type {
0x07 => {
const head = takeRoutedHead(cmd, &at);
const file_path = takeLongBytes(cmd, &at);
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.readFile(.{
.key = effect_key_base + allocEffectEntry(fx, head),
.key = effect_key_base + effect_index,
.path = file_path,
.on_result = fileResultMsg,
});
@@ -1030,13 +1050,47 @@ pub fn TsCoreHost(comptime core: type) type {
const head = takeRoutedHead(cmd, &at);
const file_path = takeLongBytes(cmd, &at);
const bytes = takeLongBytes(cmd, &at);
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.writeFile(.{
.key = effect_key_base + allocEffectEntry(fx, head),
.key = effect_key_base + effect_index,
.path = file_path,
.bytes = bytes,
.on_result = fileResultMsg,
});
},
// append/stat and streaming raw-file effects.
0x2B => {
const head = takeRoutedHead(cmd, &at);
const file_path = takeLongBytes(cmd, &at);
const bytes = takeLongBytes(cmd, &at);
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.appendFile(.{ .key = effect_key_base + effect_index, .path = file_path, .bytes = bytes, .on_result = fileResultMsg });
},
0x2C => {
const head = takeRoutedHead(cmd, &at);
const file_path = takeLongBytes(cmd, &at);
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.statFile(.{ .key = effect_key_base + effect_index, .path = file_path, .on_result = fileResultMsg });
},
0x2D => {
const key = takeShortBytes(cmd, &at);
const chunk_tag = takeByte(cmd, &at);
const done_tag = takeByte(cmd, &at);
const err_tag = takeByte(cmd, &at);
const file_path = takeLongBytes(cmd, &at);
issueReadFileStream(fx, key, chunk_tag, done_tag, err_tag, file_path);
},
0x2E => {
const head = takeRoutedHead(cmd, &at);
const file_path = takeLongBytes(cmd, &at);
issueWriteFileStream(fx, head, file_path);
},
0x2F => {
const head = takeRoutedHead(cmd, &at);
const bytes = takeLongBytes(cmd, &at);
issueWriteFileChunk(fx, head, bytes);
},
0x30 => issueWriteFileClose(fx, takeRoutedHead(cmd, &at)),
// fetch [op][key_len][key][ok][err][method u8][timeout u32 LE]
// [url_len u32 LE][url][header_count u8]
// ([name_len u8][name][value_len u32 LE][value])*
@@ -1064,11 +1118,12 @@ pub fn TsCoreHost(comptime core: type) type {
// otherwise find this named op first and leave the
// stream running. The live stream owns the key, so
// reject the newcomer through its own err arm.
if (head.key.len > 0 and findStream(head.key) != null) {
if (head.key.len > 0 and (findStream(head.key) != null or fileStreamOccupiesKey(head.key))) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
} else {
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.fetch(.{
.key = effect_key_base + allocEffectEntry(fx, head),
.key = effect_key_base + effect_index,
.method = method,
.url = url,
.headers = headers[0..header_count],
@@ -1096,8 +1151,9 @@ pub fn TsCoreHost(comptime core: type) type {
// clip_read [op][key_len][key][ok][err]
0x0B => {
const head = takeRoutedHead(cmd, &at);
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.readClipboard(.{
.key = effect_key_base + allocEffectEntry(fx, head),
.key = effect_key_base + effect_index,
.on_result = clipboardResultMsg,
});
},
@@ -1639,8 +1695,12 @@ pub fn TsCoreHost(comptime core: type) type {
/// mirrors the engine's slot count, which cannot hold more in
/// flight either (a dropped entry holds its slot only until
/// its `.cancelled` terminal drains).
fn allocEffectEntry(fx: *Fx, head: RoutedHead) u64 {
fn allocEffectEntry(fx: *Fx, head: RoutedHead) ?u64 {
if (head.key.len > 0) {
if (fileStreamOccupiesKey(head.key)) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
return null;
}
if (findEffect(head.key)) |existing| dropEffectEntry(fx, existing);
}
const index = freeEffectIndex() orelse
@@ -1684,6 +1744,10 @@ pub fn TsCoreHost(comptime core: type) type {
/// slot: the engine timer replaces in place under the same
/// engine key and restarts from now — the debounce discipline.
fn armDelay(fx: *Fx, key: []const u8, after_ms: f64, tag: u8) void {
// A delay has no err arm. Preserve an incumbent file stream and
// fail closed instead of creating a second owner that Cmd.cancel
// could not address unambiguously.
if (fileStreamOccupiesKey(key)) return;
const index = blk: {
if (key.len > 0) {
if (findDelay(key)) |existing| break :blk existing;
@@ -1737,7 +1801,7 @@ pub fn TsCoreHost(comptime core: type) type {
argv: []const []const u8,
stdin: []const u8,
) void {
if (head.key.len > 0 and findStream(head.key) != null) {
if (head.key.len > 0 and (findStream(head.key) != null or fileStreamOccupiesKey(head.key))) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
return;
}
@@ -1784,7 +1848,7 @@ pub fn TsCoreHost(comptime core: type) type {
/// two HTTP responses into one app-owned stream.
fn issueFetchStream(fx: *Fx, head: SpawnHead, options: FetchStreamOptions) void {
if (head.key.len > 0 and
(findStream(head.key) != null or findEffect(head.key) != null))
(findStream(head.key) != null or findEffect(head.key) != null or fileStreamOccupiesKey(head.key)))
{
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
return;
@@ -1831,6 +1895,148 @@ pub fn TsCoreHost(comptime core: type) type {
return null;
}
fn findFileStream(key: []const u8) ?usize {
for (&file_streams, 0..) |*entry, index| {
if (entry.used and std.mem.eql(u8, entry.wireKey(), key)) return index;
}
return null;
}
fn fileStreamOccupiesKey(key: []const u8) bool {
return key.len > 0 and findFileStream(key) != null;
}
/// Every string-keyed command family shares one authored key surface.
/// File streams use a distinct numeric engine namespace, so admission
/// must consult the bridge tables explicitly before claiming a slot.
fn wireKeyOccupiedOutsideFileStreams(key: []const u8) bool {
if (key.len == 0) return false;
return findRequest(key) != null or
findEffect(key) != null or
findStream(key) != null or
findDelay(key) != null or
findPty(key) != null or
findDb(key) != null;
}
fn freeFileStreamIndex() ?usize {
for (&file_streams, 0..) |*entry, index| if (!entry.used) return index;
return null;
}
fn issueReadFileStream(fx: *Fx, key: []const u8, chunk_tag: u8, done_tag: u8, err_tag: u8, path: []const u8) void {
if (wireKeyOccupiedOutsideFileStreams(key)) {
fx.stageLoopMsg(msgFromTagStaticBytes(err_tag, "rejected"));
return;
}
const index = if (key.len > 0 and findFileStream(key) != null) replace: {
const existing = findFileStream(key).?;
if (file_streams[existing].sink) {
fx.stageLoopMsg(msgFromTagStaticBytes(err_tag, "rejected"));
return;
}
// The engine retires the old read generation silently when
// this same engine key is reissued. Reuse the bridge slot so
// the replacement's tags become authoritative atomically.
break :replace existing;
} else freeFileStreamIndex() orelse {
fx.stageLoopMsg(msgFromTagStaticBytes(err_tag, "rejected"));
return;
};
const entry = &file_streams[index];
entry.* = .{ .used = true, .key_len = key.len, .chunk_tag = chunk_tag, .done_tag = done_tag, .err_tag = err_tag };
@memcpy(entry.key[0..key.len], key);
fx.readFileStream(.{ .key = file_stream_key_base + index, .path = path, .on_result = fileStreamResultMsg });
}
fn issueWriteFileStream(fx: *Fx, head: RoutedHead, path: []const u8) void {
if (head.key.len == 0 or findFileStream(head.key) != null or wireKeyOccupiedOutsideFileStreams(head.key)) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
return;
}
const index = freeFileStreamIndex() orelse {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
return;
};
const entry = &file_streams[index];
entry.* = .{ .used = true, .sink = true, .busy = true, .key_len = head.key.len, .done_tag = head.ok_tag, .err_tag = head.err_tag };
@memcpy(entry.key[0..head.key.len], head.key);
fx.writeFileStream(.{ .key = file_stream_key_base + index, .path = path, .on_result = fileStreamResultMsg });
}
fn issueWriteFileChunk(fx: *Fx, head: RoutedHead, bytes: []const u8) void {
const index = findFileStream(head.key) orelse {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "sink_missing"));
return;
};
if (!file_streams[index].sink) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "sink_missing"));
return;
}
if (file_streams[index].cancelling) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "sink_missing"));
return;
}
if (file_streams[index].busy) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "out_of_order"));
return;
}
file_streams[index].busy = true;
file_streams[index].done_tag = head.ok_tag;
file_streams[index].err_tag = head.err_tag;
fx.writeFileChunk(.{ .key = file_stream_key_base + index, .bytes = bytes, .on_result = fileStreamResultMsg });
}
fn issueWriteFileClose(fx: *Fx, head: RoutedHead) void {
const index = findFileStream(head.key) orelse {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "sink_missing"));
return;
};
if (!file_streams[index].sink) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "sink_missing"));
return;
}
if (file_streams[index].cancelling) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "sink_missing"));
return;
}
if (file_streams[index].busy) {
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "out_of_order"));
return;
}
file_streams[index].busy = true;
file_streams[index].done_tag = head.ok_tag;
file_streams[index].err_tag = head.err_tag;
fx.writeFileClose(.{ .key = file_stream_key_base + index, .on_result = fileStreamResultMsg });
}
fn fileStreamResultMsg(result: runtime_effects.EffectFileResult) Msg {
if (result.key < file_stream_key_base) @panic("ts core host: file stream result outside its namespace");
const index = result.key - file_stream_key_base;
if (index >= file_streams.len or !file_streams[index].used) @panic("ts core host: untracked file stream result");
const entry = &file_streams[index];
if (entry.cancelling and result.outcome == .cancelled) {
entry.used = false;
return msgFromTagBytes(entry.err_tag, "cancelled");
}
if (result.op == .read_stream and result.event == .chunk and result.outcome == .ok) return msgFromTagBytes(entry.chunk_tag, result.bytes);
if (result.op == .read_stream and result.event == .done and result.outcome == .ok) {
entry.used = false;
return msgFromTagNumber(entry.done_tag, @floatFromInt(result.total));
}
if (result.outcome == .ok) {
entry.busy = false;
if (result.op == .write_stream_close) entry.used = false;
return msgFromTagVoid(entry.done_tag);
}
if (result.op == .write_stream_chunk and (result.outcome == .rejected or result.outcome == .out_of_order)) {
entry.busy = false;
return msgFromTagBytes(entry.err_tag, @tagName(result.outcome));
}
entry.used = false;
return msgFromTagBytes(entry.err_tag, @tagName(result.outcome));
}
/// The stream entry an engine spawn/fetch result names — looked up
/// WITHOUT retiring (lines flow through it repeatedly; only the
/// terminal retires it).
@@ -2397,7 +2603,7 @@ pub fn TsCoreHost(comptime core: type) type {
term: []const u8,
argv: []const []const u8,
) void {
if (key.len > 0 and findPty(key) != null) {
if (key.len > 0 and (findPty(key) != null or fileStreamOccupiesKey(key))) {
// The rejection is STAGED (delivered a later frame), so its
// key must be self-contained: the wire key points into this
// dispatch's command buffer, gone by delivery, so intern
@@ -2510,6 +2716,10 @@ pub fn TsCoreHost(comptime core: type) type {
done_tag: u8,
err_tag: u8,
) ?usize {
if (fileStreamOccupiesKey(key)) {
fx.stageLoopMsg(msgFromTagStaticBytes(err_tag, "rejected"));
return null;
}
const index = blk: {
if (key.len > 0) {
if (findDb(key)) |existing| {
@@ -2610,6 +2820,17 @@ pub fn TsCoreHost(comptime core: type) type {
fx.cancel(spawn_key_base + index);
return;
}
if (findFileStream(key)) |index| {
if (!file_streams[index].sink) {
// Read streams are file-style: cancel is silent and the
// bridge entry retires immediately. Sinks remain loud.
file_streams[index].used = false;
} else {
file_streams[index].cancelling = true;
}
fx.cancel(file_stream_key_base + index);
return;
}
if (findDelay(key)) |index| {
fx.cancelTimer(delay_key_base + index);
delays[index].used = false;
@@ -2651,6 +2872,7 @@ pub fn TsCoreHost(comptime core: type) type {
ok_void: bool,
pool: RequestPool,
) ?u64 {
if (fileStreamOccupiesKey(key)) return null;
const index = blk: {
if (key.len > 0) {
if (findRequest(key)) |existing| {
@@ -2818,7 +3040,7 @@ pub fn TsCoreHost(comptime core: type) type {
max_pending: u8,
payload: []const u8,
) void {
if (key.len > 0 and findRequest(key) != null) {
if (key.len > 0 and (findRequest(key) != null or fileStreamOccupiesKey(key))) {
stageRequestRejected(fx, err_tag);
return;
}
@@ -2950,11 +3172,35 @@ pub fn TsCoreHost(comptime core: type) type {
if (tags.dropped) return swallowedMsg(tags.err_tag);
if (result.outcome == .ok) {
if (result.op == .read) return msgFromTagBytes(tags.ok_tag, result.bytes);
if (result.op == .stat) return msgFromTagFileStat(tags.ok_tag, result);
return msgFromTagVoid(tags.ok_tag);
}
return msgFromTagBytes(tags.err_tag, @tagName(result.outcome));
}
fn msgFromTagFileStat(tag: u8, result: runtime_effects.EffectFileResult) Msg {
inline for (msg_arms, 0..) |arm, index| {
if (tag == index) {
const info = @typeInfo(arm.type);
if (comptime info == .@"struct" and info.@"struct".fields.len == 3) {
var payload: arm.type = undefined;
inline for (info.@"struct".fields) |field| {
if (comptime std.mem.eql(u8, field.name, "exists") and field.type == bool) {
@field(payload, field.name) = result.exists;
} else if (comptime std.mem.eql(u8, field.name, "size") and (field.type == i64 or field.type == u64 or field.type == f64)) {
@field(payload, field.name) = if (comptime field.type == f64) @floatFromInt(result.total) else @intCast(result.total);
} else if (comptime std.mem.eql(u8, field.name, "mtimeMs") and (field.type == i64 or field.type == u64 or field.type == f64)) {
@field(payload, field.name) = if (comptime field.type == f64) @floatFromInt(result.mtime_ms) else @intCast(result.mtime_ms);
} else @panic("ts core host: stat_file ok arm has the wrong fields");
}
return @unionInit(Msg, arm.name, payload);
}
@panic("ts core host: stat_file ok arm must be { exists, size, mtimeMs }");
}
}
@panic("ts core host: stat_file ok tag is outside Msg");
}
/// `ResponseMsgFn` for fetch: an `.ok` un-truncated response
/// routes the ok arm as `{ status, body }`; everything else —
/// truncation included, so a cut body never parses as whole —
+95 -1
View File
@@ -1,7 +1,7 @@
//! Bridge coverage for `TsCoreHost` against a hand-written core that
//! replicates the transpiler's emitted ABI (rt kernel, commit walker,
//! `UpdateResult`/`InitResult`, wire-encoded commands and
//! subscriptions). Hand-encoding the wire records here pins the v4
//! subscriptions). Hand-encoding the wire records here pins the v5
//! byte layout independently of the rt builders that normally produce
//! it; the transpiled-fixture end-to-end suite (tests/ts-core) drives
//! the same bridge with genuinely emitted code through a full UiApp.
@@ -335,6 +335,9 @@ const mini_core = struct {
arm_full_db_live_set, // 99: fill all relational slots with live keys
replace_full_db_live_set, // 100: replace them with one disjoint key
malformed_credential, // 101: reserved request with an invalid inner record
open_save_sink, // 102: write_file_stream "save" -> wrote/failed
write_save_chunk, // 103: write_file_chunk "save" -> wrote/failed
close_save_sink, // 104: write_file_close "save" -> wrote/failed
};
const stream_fill_keys = [_][]const u8{
@@ -485,6 +488,9 @@ const mini_core = struct {
return .{ .model = out, .cmd = "" };
},
.save_file => return .{ .model = model, .cmd = cmdWriteFile("save", 12, 8, "notes.bin", model.status) },
.open_save_sink => return .{ .model = model, .cmd = cmdWriteFileStream("save", 12, 8, "notes.bin") },
.write_save_chunk => return .{ .model = model, .cmd = cmdWriteFileChunk("save", 12, 8, "next") },
.close_save_sink => return .{ .model = model, .cmd = cmdWriteFileClose("save", 12, 8) },
.wrote => {
const out = frameCreate(model.*);
out.saved = true;
@@ -990,6 +996,26 @@ const mini_core = struct {
return out;
}
fn cmdWriteFileStream(key: []const u8, ok_tag: u8, err_tag: u8, file_path: []const u8) []const u8 {
const out = rt.frameAlloc(u8, 4 + key.len + 4 + file_path.len);
var off = writeRoutedHead(out, 0x2E, key, ok_tag, err_tag);
off = writeLongBytes(out, off, file_path);
return out;
}
fn cmdWriteFileChunk(key: []const u8, ok_tag: u8, err_tag: u8, bytes: []const u8) []const u8 {
const out = rt.frameAlloc(u8, 4 + key.len + 4 + bytes.len);
var off = writeRoutedHead(out, 0x2F, key, ok_tag, err_tag);
off = writeLongBytes(out, off, bytes);
return out;
}
fn cmdWriteFileClose(key: []const u8, ok_tag: u8, err_tag: u8) []const u8 {
const out = rt.frameAlloc(u8, 4 + key.len);
_ = writeRoutedHead(out, 0x30, key, ok_tag, err_tag);
return out;
}
fn cmdFetch(key: []const u8, ok_tag: u8, err_tag: u8, method: u8, timeout_ms: u32, url: []const u8, headers: []const FetchHeader, body: []const u8) []const u8 {
var header_bytes: usize = 0;
for (headers) |h| header_bytes += 1 + h.name.len + 4 + h.value.len;
@@ -1758,6 +1784,74 @@ test "write_file routes its payload-less ok arm and err reasons" {
try std.testing.expectEqualStrings("io_failed", Host.model().last_err);
}
test "file streams and buffered effects cannot share a public key" {
const fx = freshChannel();
defer fx.deinit();
Host.init(fx);
// The buffered effect owns "save", so the sink refuses without parking a
// second engine key that would make Cmd.cancel ambiguous.
Host.dispatch(fx, .save_file);
Host.dispatch(fx, .open_save_sink);
Host.drain(fx);
try std.testing.expectEqual(@as(i64, 1), Host.model().errs);
try std.testing.expectEqualStrings("rejected", Host.model().last_err);
try std.testing.expectEqual(@as(usize, 1), fx.pendingFileCount());
try std.testing.expectError(error.EffectNotFound, fx.acknowledgeFakeFileStreamOpen(ts_core_host.file_stream_key_base));
Host.dispatch(fx, .drop_save);
Host.drain(fx);
try std.testing.expectEqual(@as(usize, 0), fx.pendingFileCount());
// The same invariant holds in the opposite order. The rejected buffered
// write does not hide the live sink, and cancel reaches that sink loudly.
Host.dispatch(fx, .open_save_sink);
try fx.acknowledgeFakeFileStreamOpen(ts_core_host.file_stream_key_base);
try fx.feedFileResultDetailed(.{ .key = ts_core_host.file_stream_key_base, .op = .write_stream_open, .outcome = .ok });
Host.drain(fx);
Host.dispatch(fx, .save_file);
Host.drain(fx);
try std.testing.expectEqual(@as(i64, 2), Host.model().errs);
try std.testing.expectEqualStrings("rejected", Host.model().last_err);
try std.testing.expectEqual(@as(usize, 0), fx.pendingFileCount());
Host.dispatch(fx, .drop_save);
Host.drain(fx);
try std.testing.expectEqual(@as(i64, 3), Host.model().errs);
try std.testing.expectEqualStrings("cancelled", Host.model().last_err);
try std.testing.expectError(error.EffectNotFound, fx.acknowledgeFakeFileStreamOpen(ts_core_host.file_stream_key_base));
}
test "a cancelling file sink rejects later chunk and close commands without orphaning callbacks" {
const fx = freshChannel();
defer fx.deinit();
Host.init(fx);
Host.dispatch(fx, .open_save_sink);
try fx.acknowledgeFakeFileStreamOpen(ts_core_host.file_stream_key_base);
try fx.feedFileResultDetailed(.{ .key = ts_core_host.file_stream_key_base, .op = .write_stream_open, .outcome = .ok });
Host.drain(fx);
// Cancel keeps the bridge entry until the engine's loud terminal arrives.
// A command in that window must reject locally instead of overwriting the
// cancellation route and queuing a second callback against the same entry.
Host.dispatch(fx, .drop_save);
Host.dispatch(fx, .write_save_chunk);
Host.drain(fx);
try std.testing.expectEqual(@as(i64, 2), Host.model().errs);
try std.testing.expectEqual(@as(usize, 0), fx.pendingFileCount());
Host.dispatch(fx, .open_save_sink);
try fx.acknowledgeFakeFileStreamOpen(ts_core_host.file_stream_key_base);
try fx.feedFileResultDetailed(.{ .key = ts_core_host.file_stream_key_base, .op = .write_stream_open, .outcome = .ok });
Host.drain(fx);
Host.dispatch(fx, .drop_save);
Host.dispatch(fx, .close_save_sink);
Host.drain(fx);
try std.testing.expectEqual(@as(i64, 4), Host.model().errs);
try std.testing.expectEqual(@as(usize, 0), fx.pendingFileCount());
}
test "fetch decodes the wire record whole and routes the { status, body } ok arm by field type" {
const fx = freshChannel();
defer fx.deinit();
+22 -1
View File
@@ -1448,6 +1448,18 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
.permitted = runtime.options.credentials_enabled and
security.hasPermission(runtime.options.security.permissions, security.permission_credentials),
});
if (runtime.options.file_access) |binding| {
self.effects.bindFileAccess(binding);
} else if (!builtin.is_test and !self.effects.replayArmed()) {
// Fail closed for custom/older runners that omit the new path
// policy. Replay is exempt: the journal is the whole world and
// fake file requests must park without consulting live paths.
self.effects.bindFileAccess(.{
.roots = &.{},
.permitted = security.hasPermission(runtime.options.security.permissions, security.permission_filesystem),
.enforce = true,
});
}
self.effects.bindImages(runtime.canvasImageRegistryBinding());
self.effects.bindMediaSurfaces(runtime.mediaSurfaceBinding());
self.effects.bindWindowActions(.{
@@ -1523,7 +1535,16 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
record.truncated,
record.dropped,
),
.file => try self.effects.feedFileResult(record.key, record.file_outcome, record.payload),
.file => try self.effects.feedFileResultDetailed(.{
.key = record.key,
.op = record.file_op,
.event = record.file_event,
.outcome = record.file_outcome,
.bytes = record.payload,
.total = record.file_total,
.mtime_ms = record.file_mtime_ms,
.exists = record.file_exists,
}),
.clipboard => try self.effects.feedClipboardResult(record.key, record.clipboard_outcome, record.payload),
// `.host` records ride the route in `code` (0 ok / 1
// err); rejections never reach here — they carry
+28
View File
@@ -2337,6 +2337,8 @@ fn runnerZig() []const u8 {
\\ shortcuts: ?[]const native_sdk.Shortcut = null,
\\ record_store: ?native_sdk.RecordStoreBinding = null,
\\ relational_store: ?native_sdk.RelationalStoreBinding = null,
\\ file_access: ?native_sdk.FileAccessBinding = null,
\\ file_access_enforce: bool = true,
\\ relational_migrations: []const native_sdk.relational_store.Migration = &built_relational_migrations.migrations,
\\
\\ fn appInfo(self: RunOptions, buffers: *StateBuffers) native_sdk.AppInfo {
@@ -2732,6 +2734,24 @@ fn runnerZig() []const u8 {
\\ var record_store_value: RecordStoreType = undefined;
\\ var record_store_open = false;
\\ var resolved_options = options;
\\ var file_root_buffers: [6][1024]u8 = undefined;
\\ var file_roots: [6][]const u8 = undefined;
\\ const resolved_file_dirs = native_sdk.app_dirs.resolve(
\\ .{ .name = options.bundle_id },
\\ native_sdk.app_dirs.currentPlatform(),
\\ native_sdk.debug.envFromMap(init.environ_map),
\\ native_sdk.app_dirs.Buffers.fromArray(1024, &file_root_buffers),
\\ ) catch null;
\\ var file_root_count: usize = 0;
\\ if (resolved_file_dirs) |dirs| {
\\ file_roots = .{ dirs.config, dirs.cache, dirs.data, dirs.state, dirs.logs, dirs.temp };
\\ file_root_count = file_roots.len;
\\ }
\\ resolved_options.file_access = .{
\\ .roots = file_roots[0..file_root_count],
\\ .permitted = native_sdk.security.hasPermission(options.security.permissions, native_sdk.security.permission_filesystem),
\\ .enforce = options.file_access_enforce,
\\ };
\\ if (comptime manifestDeclaresStore()) {
\\ var data_dir_buffer: [512]u8 = undefined;
\\ const app_data_dir = native_sdk.app_dirs.resolveOne(
@@ -2835,6 +2855,7 @@ fn runnerZig() []const u8 {
\\ .window_state_store = store,
\\ .record_store = options.record_store,
\\ .relational_store = options.relational_store,
\\ .file_access = options.file_access,
\\ .environ = init.minimal.environ,
\\ });
\\
@@ -2891,6 +2912,7 @@ fn runnerZig() []const u8 {
\\ .window_state_store = store,
\\ .record_store = options.record_store,
\\ .relational_store = options.relational_store,
\\ .file_access = options.file_access,
\\ .environ = init.minimal.environ,
\\ });
\\
@@ -2947,6 +2969,7 @@ fn runnerZig() []const u8 {
\\ .window_state_store = store,
\\ .record_store = options.record_store,
\\ .relational_store = options.relational_store,
\\ .file_access = options.file_access,
\\ .environ = init.minimal.environ,
\\ });
\\
@@ -3003,6 +3026,7 @@ fn runnerZig() []const u8 {
\\ .window_state_store = store,
\\ .record_store = options.record_store,
\\ .relational_store = options.relational_store,
\\ .file_access = options.file_access,
\\ .environ = init.minimal.environ,
\\ });
\\
@@ -4082,6 +4106,10 @@ test "writeDefaultApp emits Vite project files" {
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "relational_store: ?native_sdk.RelationalStoreBinding = null") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "fn manifestDeclaresSqlite()") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".relational_store = options.relational_store") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "file_access: ?native_sdk.FileAccessBinding = null") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "file_access_enforce: bool = true") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "native_sdk.app_dirs.Buffers.fromArray") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, ".file_access = options.file_access") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "resolvedShortcuts") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "const manifest_windows") != null);
try std.testing.expect(std.mem.indexOf(u8, runner_zig_text, "fn appInfo(self: RunOptions, buffers: *StateBuffers)") != null);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"format": 1,
"wire_version": 4,
"wire_version": 5,
"abi_version": 2,
"compiler_version": "0.0.1",
"entry": "tests/sidecar/integer_fixture.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"format": 1,
"wire_version": 4,
"wire_version": 5,
"abi_version": 2,
"compiler_version": "0.0.1",
"entry": "tests/ts-core/markup_fixture.ts",
+37
View File
@@ -95,6 +95,8 @@ export interface Model {
readonly rejectSeq: number;
readonly chanRejectAt: number;
readonly imgRejectAt: number;
readonly fileTotal: number;
readonly fileExists: boolean;
}
export type Msg =
@@ -109,6 +111,16 @@ export type Msg =
| { readonly kind: "stamped"; readonly at: number }
| { readonly kind: "save" }
| { readonly kind: "load" }
| { readonly kind: "file_stat"; readonly exists: boolean; readonly size: number; readonly mtimeMs: number }
| { readonly kind: "stat_file" }
| { readonly kind: "append_file" }
| { readonly kind: "stream_read" }
| { readonly kind: "stream_open" }
| { readonly kind: "stream_chunk" }
| { readonly kind: "stream_close" }
| { readonly kind: "stream_out_of_order" }
| { readonly kind: "stream_piece"; readonly bytes: Uint8Array }
| { readonly kind: "stream_done"; readonly total: number }
| { readonly kind: "wrote" }
| { readonly kind: "get" }
| { readonly kind: "fetched"; readonly status: number; readonly body: Uint8Array }
@@ -217,6 +229,8 @@ export function initialModel(): [Model, Cmd<Msg>] {
rejectSeq: 0,
chanRejectAt: -1,
imgRejectAt: -1,
fileTotal: 0,
fileExists: false,
},
Cmd.request("status.read", asciiBytes("boot"), { key: "status", ok: "loaded", err: "failed" }),
];
@@ -248,6 +262,29 @@ export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
return [model, Cmd.writeFile(asciiBytes(".zig-cache/tmp/ts-core-e2e/store.bin"), model.status, { key: "file", ok: "wrote", err: "failed" })];
case "load":
return [model, Cmd.readFile(asciiBytes(".zig-cache/tmp/ts-core-e2e/store.bin"), { key: "file", ok: "loaded", err: "failed" })];
case "file_stat":
return [{ ...model, fileTotal: msg.size, fileExists: msg.exists }, Cmd.none];
case "stat_file":
return [model, Cmd.statFile(asciiBytes(".zig-cache/tmp/ts-core-tier5/append.bin"), { key: "file", ok: "file_stat", err: "failed" })];
case "append_file":
return [model, Cmd.appendFile(asciiBytes(".zig-cache/tmp/ts-core-tier5/append.bin"), model.status, { key: "file", ok: "wrote", err: "failed" })];
case "stream_open":
return [model, Cmd.writeFileStream("file-stream", asciiBytes(".zig-cache/tmp/ts-core-tier5/stream.bin"), { ok: "wrote", err: "failed" })];
case "stream_chunk":
return [model, Cmd.writeFileChunk("file-stream", model.status, { ok: "wrote", err: "failed" })];
case "stream_close":
return [model, Cmd.writeFileClose("file-stream", { ok: "wrote", err: "failed" })];
case "stream_out_of_order":
return [model, Cmd.batch([
Cmd.writeFileChunk("file-stream", model.status, { ok: "wrote", err: "failed" }),
Cmd.writeFileChunk("file-stream", model.status, { ok: "wrote", err: "failed" }),
])];
case "stream_read":
return [model, Cmd.readFileStream(asciiBytes(".zig-cache/tmp/ts-core-tier5/stream.bin"), { key: "read-stream", chunk: "stream_piece", done: "stream_done", err: "failed" })];
case "stream_piece":
return [{ ...model, status: msg.bytes }, Cmd.none];
case "stream_done":
return [{ ...model, fileTotal: msg.total }, Cmd.none];
case "wrote":
return [{ ...model, saved: (model.saved < 9007199254740991 ? model.saved + 1 : 9007199254740991) }, Cmd.none];
case "get":
+76
View File
@@ -65,6 +65,13 @@ fn e2eCommand(name: []const u8) ?fixture.Msg {
if (std.mem.eql(u8, name, "core.note")) return .note;
if (std.mem.eql(u8, name, "core.save")) return .save;
if (std.mem.eql(u8, name, "core.load")) return .load;
if (std.mem.eql(u8, name, "core.filestat")) return .stat_file;
if (std.mem.eql(u8, name, "core.fileappend")) return .append_file;
if (std.mem.eql(u8, name, "core.streamopen")) return .stream_open;
if (std.mem.eql(u8, name, "core.streamchunk")) return .stream_chunk;
if (std.mem.eql(u8, name, "core.streamclose")) return .stream_close;
if (std.mem.eql(u8, name, "core.streamooo")) return .stream_out_of_order;
if (std.mem.eql(u8, name, "core.streamread")) return .stream_read;
if (std.mem.eql(u8, name, "core.get")) return .get;
if (std.mem.eql(u8, name, "core.stream")) return .stream;
if (std.mem.eql(u8, name, "core.cancelstream")) return .cancel_stream;
@@ -120,11 +127,17 @@ fn e2eCommand(name: []const u8) ?fixture.Msg {
/// tests so every run starts from an absent store.
const store_path = ".zig-cache/tmp/ts-core-e2e/store.bin";
const store_dir = ".zig-cache/tmp/ts-core-e2e";
const tier5_dir = ".zig-cache/tmp/ts-core-tier5";
const tier5_append_path = ".zig-cache/tmp/ts-core-tier5/append.bin";
fn removeStore() void {
std.Io.Dir.cwd().deleteTree(std.testing.io, store_dir) catch {};
}
fn removeTier5Files() void {
std.Io.Dir.cwd().deleteTree(std.testing.io, tier5_dir) catch {};
}
fn e2eOptions() App.Options {
return .{
.name = "ts-core-e2e",
@@ -261,6 +274,15 @@ const Harness = struct {
.size = native_sdk.geometry.SizeF.init(400, 300),
});
errdefer self.harness.destroy(std.testing.allocator);
self.harness.runtime.options.security.permissions = &.{
native_sdk.security.permission_credentials,
native_sdk.security.permission_filesystem,
};
self.harness.runtime.options.file_access = .{
.roots = &.{},
.permitted = true,
.enforce = true,
};
self.harness.null_platform.gpu_surfaces = true;
self.harness.runtime.options.session_recorder = recorder;
self.app_state = try std.testing.allocator.create(App);
@@ -569,6 +591,60 @@ test "writeFile and readFile round-trip real disk through the compiled core" {
try std.testing.expectEqualStrings("ready", Bridge.model().status);
}
test "compiled stat, append, and file-stream verbs route through the runtime" {
const io = std.testing.io;
HostStub.reset();
removeTier5Files();
defer removeTier5Files();
const h = try Harness.create();
defer h.destroy();
const fx = &h.app_state.effects;
try fx.feedHostResult(status_request_key, true, "chunk-bytes");
try h.wake();
try h.menu("core.streamopen");
try h.waitPending();
try h.wake();
// Two chunks in one command batch: the second refuses out_of_order at
// its own command-stream position without stealing the first chunk's
// route tags. The accepted first chunk still lands and is acknowledged.
const saved_before_chunk = Bridge.model().saved;
try h.menu("core.streamooo");
try h.wake();
try std.testing.expectEqualStrings("out_of_order", Bridge.model().lastErr);
if (Bridge.model().saved == saved_before_chunk) {
try h.waitPending();
try h.wake();
}
try h.menu("core.streamclose");
try h.waitPending();
try h.wake();
try h.menu("core.streamread");
while (true) {
try h.waitPending();
try h.wake();
if (Bridge.model().fileTotal == "chunk-bytes".len) break;
}
try std.testing.expectEqualStrings("chunk-bytes", Bridge.model().status);
try std.testing.expectEqual(@as(f64, "chunk-bytes".len), Bridge.model().fileTotal);
try std.Io.Dir.cwd().createDirPath(io, tier5_dir);
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tier5_append_path, .data = "chunk-bytes" });
try h.menu("core.fileappend");
try h.waitPending();
try h.wake();
try h.menu("core.filestat");
try h.waitPending();
try h.wake();
try std.testing.expect(Bridge.model().fileExists);
try std.testing.expectEqual(@as(f64, "chunk-bytes".len * 2), Bridge.model().fileTotal);
const appended = try std.Io.Dir.cwd().readFileAlloc(io, tier5_append_path, std.testing.allocator, .limited(4096));
defer std.testing.allocator.free(appended);
try std.testing.expectEqualStrings("chunk-byteschunk-bytes", appended);
}
test "every Cmd.store factory emits its bounded v3 record through the external core" {
HostStub.reset();
const h = try Harness.createFake();
+3 -3
View File
@@ -1764,7 +1764,7 @@ test "a u64 attestation on chrome geometry refuses at check time" {
// class cannot carry.
const source =
\\{
\\ "format": 1, "wire_version": 4, "abi_version": 2,
\\ "format": 1, "wire_version": 5, "abi_version": 2,
\\ "compiler_version": "0.0.1", "entry": "src/core.ts",
\\ "source_hash": "00000000c0ffee00", "build_id": "00000000b01dface", "model_fingerprint": "00000000a11ce001",
\\ "types": {
@@ -1945,7 +1945,7 @@ test "a shared authored type spelling like a synthesized name stays a top-level
// first site would leave the second dangling.
const source =
\\{
\\ "format": 1, "wire_version": 4, "abi_version": 2,
\\ "format": 1, "wire_version": 5, "abi_version": 2,
\\ "compiler_version": "0.0.1", "entry": "src/core.ts",
\\ "source_hash": "00000000c0ffee00", "build_id": "00000000b01dface", "model_fingerprint": "00000000a11ce001",
\\ "types": {
@@ -2381,7 +2381,7 @@ test "a chrome arm holding its insets by reference refuses" {
// node (by-reference) insets record cannot take that construction.
const source =
\\{
\\ "format": 1, "wire_version": 4, "abi_version": 2,
\\ "format": 1, "wire_version": 5, "abi_version": 2,
\\ "compiler_version": "0.0.1", "entry": "src/core.ts",
\\ "source_hash": "00000000c0ffee00", "build_id": "00000000b01dface", "model_fingerprint": "00000000a11ce001",
\\ "types": {
+44 -1
View File
@@ -2663,7 +2663,7 @@ const FacadeEmitter = struct {
\\// ---------------------------------------------------- the cmd wire
\\// Encoder for the inert Cmd data the author's update returns —
\\// byte-for-byte the layouts the host's command decoder expects
\\// (cmd_format_version 4). nscfTagOf maps a Msg arm name onto its
\\// (cmd_format_version 5). nscfTagOf maps a Msg arm name onto its
\\// declaration-order wire tag.
\\
\\const nscfFetchMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"];
@@ -2772,6 +2772,49 @@ const FacadeEmitter = struct {
\\ nscfWBytes(sink, cmd.path);
\\ nscfWBytes(sink, cmd.bytes);
\\ return;
\\ case "append_file":
\\ nscfWU8(sink, 0x2b);
\\ nscfWShortText(sink, cmd.key);
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ nscfWBytes(sink, cmd.path);
\\ nscfWBytes(sink, cmd.bytes);
\\ return;
\\ case "stat_file":
\\ nscfWU8(sink, 0x2c);
\\ nscfWShortText(sink, cmd.key);
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ nscfWBytes(sink, cmd.path);
\\ return;
\\ case "read_file_stream":
\\ nscfWU8(sink, 0x2d);
\\ nscfWShortText(sink, cmd.key);
\\ nscfWU8(sink, nscfTagOf(cmd.chunkKind));
\\ nscfWU8(sink, nscfTagOf(cmd.doneKind));
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ nscfWBytes(sink, cmd.path);
\\ return;
\\ case "write_file_stream":
\\ nscfWU8(sink, 0x2e);
\\ nscfWShortText(sink, cmd.key);
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ nscfWBytes(sink, cmd.path);
\\ return;
\\ case "write_file_chunk":
\\ nscfWU8(sink, 0x2f);
\\ nscfWShortText(sink, cmd.key);
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ nscfWBytes(sink, cmd.bytes);
\\ return;
\\ case "write_file_close":
\\ nscfWU8(sink, 0x30);
\\ nscfWShortText(sink, cmd.key);
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ return;
\\ case "fetch": {
\\ nscfWU8(sink, 0x09);
\\ nscfWShortText(sink, cmd.key);
+2 -2
View File
@@ -394,7 +394,7 @@ test "profile emission is deterministic and carries the library-mode surface" {
// declares the SDK's emission path, identity-getter symbols, the
// facade's designated entries, and the integer-slot declarations.
try testing.expect(std.mem.indexOf(u8, first, "\"path\": \"core.contract.json\"") != null);
try testing.expect(std.mem.indexOf(u8, first, "\"wire_version\": 4") != null);
try testing.expect(std.mem.indexOf(u8, first, "\"wire_version\": 5") != null);
try testing.expect(std.mem.indexOf(u8, first, "\"build_id_symbol\": \"nsc_core_build_id\"") != null);
try testing.expect(std.mem.indexOf(u8, first, "\"abi_version_symbol\": \"nsc_core_abi_version\"") != null);
try testing.expect(std.mem.indexOf(u8, first, "\"init_export\": \"init\"") != null);
@@ -440,7 +440,7 @@ test "the profile tracks the contract's prefix and generations" {
defer arena_state.deinit();
const arena = arena_state.allocator();
var source = try std.mem.replaceOwned(u8, arena, sidecar_mod.minimal_valid_json, "\"prefix\": \"nsc_core_\"", "\"prefix\": \"app2_\"");
source = try std.mem.replaceOwned(u8, arena, source, "\"wire_version\": 4", "\"wire_version\": 4");
source = try std.mem.replaceOwned(u8, arena, source, "\"wire_version\": 5", "\"wire_version\": 5");
const generated = try profileFromJson(arena, source, "my_facade.ts");
try testing.expect(std.mem.indexOf(u8, generated, "\"entry\": \"my_facade.ts\"") != null);
try testing.expect(std.mem.indexOf(u8, generated, "\"prefix\": \"app2_\"") != null);
+4 -4
View File
@@ -34,7 +34,7 @@ pub const supported_format: i64 = 1;
/// The command-wire vocabulary generation the SDK's bridge speaks
/// (rt.zig `cmd_format_version`). A sidecar declaring a different
/// generation is refused at generate time.
pub const supported_wire_version: i64 = 4;
pub const supported_wire_version: i64 = 5;
/// The C-ABI generation of the core entry points this generator binds
/// (core_abi.zig `abi_version`).
@@ -1832,7 +1832,7 @@ const testing = std.testing;
pub const minimal_valid_json =
\\{
\\ "format": 1,
\\ "wire_version": 4,
\\ "wire_version": 5,
\\ "abi_version": 2,
\\ "compiler_version": "0.0.1",
\\ "entry": "src/core.ts",
@@ -2428,8 +2428,8 @@ test "unknown payload descriptor kinds refuse as reader-too-old" {
test "wire and abi version mismatches refuse with both values named" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const source = try replaced(arena_state.allocator(), minimal_valid_json, "\"wire_version\": 4", "\"wire_version\": 5");
try expectRefusal(source, "wire_version", "generation 4, the sidecar declares 5");
const source = try replaced(arena_state.allocator(), minimal_valid_json, "\"wire_version\": 5", "\"wire_version\": 6");
try expectRefusal(source, "wire_version", "generation 5, the sidecar declares 6");
}
test "unknown fields warn and are ignored" {