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:
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -4,3 +4,4 @@ next-env.d.ts
|
||||
.next-gate/
|
||||
.next-agent/
|
||||
.next-check/
|
||||
.next-final/
|
||||
|
||||
@@ -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()`:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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(.{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -279,6 +279,14 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.writeFile(path, bytes, { key?, ok, err })</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>{ exists, size, mtimeMs }</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 & Streaming</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.fetch(spec, { key?, ok, err })</code></td>
|
||||
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>{ status, body }</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).
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 = .{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+60
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–64 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`.
|
||||
|
||||
@@ -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)`
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
@@ -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", .{}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user