diff --git a/docs/src/app/docs/files/page.mdx b/docs/src/app/docs/files/page.mdx
index 3faea142..907f66fa 100644
--- a/docs/src/app/docs/files/page.mdx
+++ b/docs/src/app/docs/files/page.mdx
@@ -8,16 +8,21 @@ The blob directory is deduplicated but currently has no quota or automatic GC; s
## 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.
+`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, `Cmd.statFile` reports `{ exists, size, mtimeMs }` without reading the content, and `Cmd.deleteFile` removes one file.
```ts
return [model, Cmd.statFile(path, {
ok: "file_stat",
err: "file_failed",
})];
+
+return [model, Cmd.deleteFile(path, {
+ ok: "file_deleted",
+ 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`.
+Deletion is file-only and never recursive: it reports `not_found` when the path is absent and `io_failed` for a directory or another OS refusal. If the final path component is a symlink, deletion removes the link rather than its target. 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
@@ -58,7 +63,7 @@ Raw paths inside this app's resolved `data`, `config`, `cache`, `state`, `logs`,
.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.
+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 for authorization, so a symlink inside an app directory that points outside is external and requires the permission. Deletion still unlinks the final symlink itself after that check. `..` 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.
diff --git a/docs/src/app/docs/native-ui/page.mdx b/docs/src/app/docs/native-ui/page.mdx
index 398c1339..52221f79 100644
--- a/docs/src/app/docs/native-ui/page.mdx
+++ b/docs/src/app/docs/native-ui/page.mdx
@@ -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 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).
+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`, `statFile`, and `deleteFile` handle logs and file lifecycle, 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(.{
diff --git a/docs/src/app/docs/typescript/page.mdx b/docs/src/app/docs/typescript/page.mdx
index e44eef3b..1d98be01 100644
--- a/docs/src/app/docs/typescript/page.mdx
+++ b/docs/src/app/docs/typescript/page.mdx
@@ -285,8 +285,8 @@ The runtime interprets the command after the model commits and dispatches any re
Write a whole file (parents created, replaced whole); ok carries no payload — a successful write has nothing to report |
- Cmd.appendFile / Cmd.statFile |
- Append one bounded payload, or inspect { exists, size, mtimeMs } before choosing how to read a file |
+ Cmd.appendFile / Cmd.statFile / Cmd.deleteFile |
+ Append one bounded payload, inspect { exists, size, mtimeMs }, or delete one file with explicit not_found handling |
Cmd.readFileStream / Cmd.writeFileStream + writeFileChunk/writeFileClose |
diff --git a/evals/harness-lib/cmdview.zig b/evals/harness-lib/cmdview.zig
index 6d0ea9db..3ac109b6 100644
--- a/evals/harness-lib/cmdview.zig
+++ b/evals/harness-lib/cmdview.zig
@@ -1,5 +1,5 @@
//! Decoder over the app-core Cmd/Sub wire format (rt.zig, cmd_format_version
-//! 6), shared by the ts-track behavioral harnesses. The graders copy this
+//! 7), 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
@@ -31,6 +31,7 @@ pub const Op = union(enum) {
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 },
+ delete_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 },
@@ -598,6 +599,10 @@ pub const CmdIter = struct {
const head = routedHead(b, &off);
break :blk .{ .write_file_close = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err } };
},
+ 0x32 => blk: {
+ const head = routedHead(b, &off);
+ break :blk .{ .delete_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
+ },
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
};
self.off = off;
@@ -763,6 +768,23 @@ test "record store command records decode and advance exactly" {
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
+test "delete_file decodes and advances a batch exactly" {
+ const batch = [_]u8{
+ 0x32, 4, 'f', 'i', 'l', 'e', 2, 3,
+ 12, 0, 0, 0, 'o', 'b', 's', 'o',
+ 'l', 'e', 't', 'e', '.', 'b', 'i', 'n',
+ 0x02, 7,
+ };
+ var iter = CmdIter.init(&batch);
+ const deleted = (iter.next() orelse return error.TestUnexpectedResult).delete_file;
+ try std.testing.expectEqualStrings("file", deleted.key);
+ try std.testing.expectEqual(@as(u8, 2), deleted.ok_tag);
+ try std.testing.expectEqual(@as(u8, 3), deleted.err_tag);
+ try std.testing.expectEqualStrings("obsolete.bin", deleted.path);
+ try std.testing.expectEqual(@as(u8, 7), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
+ try std.testing.expectEqual(@as(?Op, null), iter.next());
+}
+
test "the image records decode, alone and inside a batch" {
// image_load: [op 0x12][id f64 LE][event_tag][path][url][cache]
// [expected f64 LE] — the bytes rt.zig's cmdImageLoad pins (the same
diff --git a/packages/core/compile-surface/core.ts b/packages/core/compile-surface/core.ts
index c43337ff..bc2d0d06 100644
--- a/packages/core/compile-surface/core.ts
+++ b/packages/core/compile-surface/core.ts
@@ -370,6 +370,7 @@ export type CmdData =
}
| { 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: "delete_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 }
@@ -725,6 +726,9 @@ export const Cmd = {
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 };
},
+ deleteFile(path: Uint8Array, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
+ return { op: "delete_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 };
},
diff --git a/packages/core/sdk/core.d.ts b/packages/core/sdk/core.d.ts
index 43af43ed..ddbb12e0 100644
--- a/packages/core/sdk/core.d.ts
+++ b/packages/core/sdk/core.d.ts
@@ -317,6 +317,12 @@ export type Cmd = {
readonly okKind: string;
readonly errKind: string;
readonly path: Uint8Array;
+} | {
+ readonly op: "delete_file";
+ readonly key: string;
+ readonly okKind: string;
+ readonly errKind: string;
+ readonly path: Uint8Array;
} | {
readonly op: "read_file_stream";
readonly key: string;
@@ -562,6 +568,7 @@ export declare const Cmd: {
writeFile(path: Uint8Array, bytes: Uint8Array, route: WriteRoute): Cmd;
appendFile(path: Uint8Array, bytes: Uint8Array, route: WriteRoute): Cmd;
statFile(path: Uint8Array, route: FileStatRoute): Cmd;
+ deleteFile(path: Uint8Array, route: WriteRoute): Cmd;
readFileStream(path: Uint8Array, route: FileReadStreamRoute): Cmd;
writeFileStream(key: string, path: Uint8Array, route: WriteRoute): Cmd;
writeFileChunk(key: string, bytes: Uint8Array, route: WriteRoute): Cmd;
diff --git a/packages/core/sdk/core.ts b/packages/core/sdk/core.ts
index b39e1d19..5b86209b 100644
--- a/packages/core/sdk/core.ts
+++ b/packages/core/sdk/core.ts
@@ -54,7 +54,8 @@
// 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.appendFile / statFile / deleteFile
+// bounded append, metadata probe, and deletion
// Cmd.readFileStream 256-KiB chunks, then done(total) or err
// Cmd.writeFileStream / writeFileChunk / writeFileClose
// atomic streamed sink; chunks are acknowledged
@@ -1155,6 +1156,13 @@ export type Cmd =
readonly errKind: string;
readonly path: Uint8Array;
}
+ | {
+ readonly op: "delete_file";
+ readonly key: string;
+ readonly okKind: string;
+ readonly errKind: string;
+ readonly path: Uint8Array;
+ }
| {
readonly op: "read_file_stream";
readonly key: string;
@@ -1623,6 +1631,13 @@ export const Cmd = {
return { op: "stat_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path };
},
+ /// Delete one file. The ok arm carries no payload. A final symlink is
+ /// unlinked without deleting its target. A missing file routes the err arm
+ /// with "not_found"; directories and other OS refusals route "io_failed".
+ deleteFile(path: Uint8Array, route: WriteRoute): Cmd {
+ return { op: "delete_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path };
+ },
+
readFileStream(path: Uint8Array, route: FileReadStreamRoute): Cmd {
return { op: "read_file_stream", key: route.key ?? "", chunkKind: route.chunk, doneKind: route.done, errKind: route.err, path };
},
diff --git a/packages/core/src/checker.ts b/packages/core/src/checker.ts
index b36f7655..bfab8e26 100644
--- a/packages/core/src/checker.ts
+++ b/packages/core/src/checker.ts
@@ -2631,7 +2631,7 @@ export class SubsetChecker {
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
- ["readFile", "writeFile", "appendFile", "statFile", "readFileStream", "writeFileStream"].includes(node.expression.name.text) &&
+ ["readFile", "writeFile", "appendFile", "statFile", "deleteFile", "readFileStream", "writeFileStream"].includes(node.expression.name.text) &&
ts.isIdentifier(node.expression.expression) &&
this.cmdNames.has(node.expression.expression.text) &&
this.isSdkReference(node.expression.expression) &&
diff --git a/packages/core/src/contract.ts b/packages/core/src/contract.ts
index 437babd4..4f421803 100644
--- a/packages/core/src/contract.ts
+++ b/packages/core/src/contract.ts
@@ -667,7 +667,7 @@ class ContractEmitter {
return (
"{\n" +
' "format": 1,\n' +
- ' "wire_version": 6,\n' +
+ ' "wire_version": 7,\n' +
' "abi_version": 2,\n' +
' "compiler_version": "0.0.1",\n' +
` "entry": ${js(this.entry)},\n` +
diff --git a/packages/core/src/devhost.ts b/packages/core/src/devhost.ts
index 7f52d953..d31786e3 100644
--- a/packages/core/src/devhost.ts
+++ b/packages/core/src/devhost.ts
@@ -1796,6 +1796,7 @@ function performCmd(cmd: Cmdish): void {
case "write_file":
case "append_file":
case "stat_file":
+ case "delete_file":
case "read_file_stream":
case "write_file_stream":
case "write_file_chunk":
diff --git a/packages/core/src/devhost_journal.mjs b/packages/core/src/devhost_journal.mjs
index 608d63c4..c9f67771 100644
--- a/packages/core/src/devhost_journal.mjs
+++ b/packages/core/src/devhost_journal.mjs
@@ -3,7 +3,9 @@ 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 = 0x886975504b050ed4n;
+// EffectFileOp appended `delete`; the reflected journal layout fingerprint
+// moves so older recordings refuse cleanly instead of decoding op 8 wrongly.
+export const journalFormatFingerprint = 0xb3bd2e83971de44dn;
export const automationProtocolFingerprint = 0x59d66f39803fd602n;
const requestKeyBase = 0x5453525100000000n;
diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts
index 3a89a00f..90a820e4 100644
--- a/packages/core/src/diagnostics.ts
+++ b/packages/core/src/diagnostics.ts
@@ -546,6 +546,7 @@ export const rules = {
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.",
+ class: "guarantee",
},
NS1420: {
id: "NS1420",
diff --git a/packages/core/test/checker.test.ts b/packages/core/test/checker.test.ts
index b225a7d8..8b83d6c4 100644
--- a/packages/core/test/checker.test.ts
+++ b/packages/core/test/checker.test.ts
@@ -178,6 +178,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] {
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.deleteFile(asciiBytes("/tmp/obsolete"), { ok: "wrote", err: "failed" }),
Cmd.readFileStream(asciiBytes("safe/import"), { chunk: "loaded", done: "done", err: "failed" }),
Cmd.writeFileStream("sink", asciiBytes("/tmp/export"), { ok: "wrote", err: "failed" }),
])];
@@ -190,7 +191,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] {
}
`;
const denied = check(source);
- assert.equal(denied.diagnostics.filter((d) => d.id === "NS1074").length, 4);
+ assert.equal(denied.diagnostics.filter((d) => d.id === "NS1074").length, 5);
const granted = check(source, { permissions: ["filesystem"] });
assert.equal(granted.diagnostics.some((d) => d.id === "NS1074"), false);
});
diff --git a/packages/core/test/conformance.test.ts b/packages/core/test/conformance.test.ts
index 13cdabb4..e7504e32 100644
--- a/packages/core/test/conformance.test.ts
+++ b/packages/core/test/conformance.test.ts
@@ -1320,6 +1320,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] {
case "go":
if (msg.which === 0) return [model, Cmd.readFile(asciiBytes("a.bin"), { key: "r", ok: "loaded", err: "failed" })];
if (msg.which === 1) return [model, Cmd.writeFile(asciiBytes("a.bin"), model.data, { key: "w", ok: "wrote", err: "failed" })];
+ if (msg.which === 14) return [model, Cmd.deleteFile(asciiBytes("a.bin"), { key: "x", ok: "wrote", err: "failed" })];
if (msg.which === 2) return [model, Cmd.fetch({ url: asciiBytes("https://a.test"), method: "PUT", headers: { accept: "text/plain" }, body: model.data, timeoutMs: 1000 }, { ok: "fetched", err: "failed" })];
if (msg.which === 3) return [model, Cmd.fetch({ url: model.data }, { key: "g", ok: "fetched", err: "failed" })];
if (msg.which === 4) return [model, Cmd.clipboardWrite(model.data)];
diff --git a/packages/core/test/contract.test.ts b/packages/core/test/contract.test.ts
index 7394f8b2..0c95ee04 100644
--- a/packages/core/test/contract.test.ts
+++ b/packages/core/test/contract.test.ts
@@ -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, 6);
+ assert.equal(doc.wire_version, 7);
assert.equal(doc.abi_version, 2);
assert.equal(doc.entry, "src/core.ts");
assert.equal(doc.model, "Model");
diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md
index 9251f6b7..380a744d 100644
--- a/skill-data/native-ui/SKILL.md
+++ b/skill-data/native-ui/SKILL.md
@@ -710,7 +710,7 @@ File rules:
- `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.
+- `appendFile` appends one payload up to 1 MiB; `statFile` returns existence, size, and mtime; `deleteFile` deletes one file, returning `.not_found` when it is absent and unlinking a final symlink without deleting its target. `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.
diff --git a/skill-data/ts-core/SKILL.md b/skill-data/ts-core/SKILL.md
index 446b528a..f41530b4 100644
--- a/skill-data/ts-core/SKILL.md
+++ b/skill-data/ts-core/SKILL.md
@@ -121,7 +121,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] {
}
```
-The command set (Cmd wire format v6):
+The command set (Cmd wire format v7):
- `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.
@@ -141,7 +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.appendFile(path, bytes, route)` appends one bounded payload; `Cmd.statFile(path, route)` returns `{ exists, size, mtimeMs }`; `Cmd.deleteFile(path, route)` deletes one file with a payload-less `ok` arm and routes a missing path as `not_found` (a final symlink is unlinked without deleting its target). `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`.
diff --git a/skill-data/zig/SKILL.md b/skill-data/zig/SKILL.md
index a2c8afdc..b8689127 100644
--- a/skill-data/zig/SKILL.md
+++ b/skill-data/zig/SKILL.md
@@ -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`/`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.
+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`/`deleteFile`/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)`
diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig
index f20c4f08..5e689f7c 100644
--- a/src/runtime/effects.zig
+++ b/src/runtime/effects.zig
@@ -613,6 +613,7 @@ pub const EffectFileOp = enum {
write_stream_open,
write_stream_chunk,
write_stream_close,
+ delete,
};
/// Whether a file result is an ordinary one-shot terminal or one delivery
@@ -628,7 +629,7 @@ pub const EffectFileOutcome = enum {
/// The operation completed. Reads carry the whole file in `bytes`;
/// writes wrote every byte (parent directories created as needed).
ok,
- /// The file does not exist (reads only — writes create the path).
+ /// The file does not exist (reads and deletes only — writes create the path).
not_found,
/// The OS refused: permissions, the path names a directory, disk
/// errors, an unwritable parent — anything but absence.
@@ -653,7 +654,7 @@ pub const EffectFileOutcome = enum {
};
/// Payload for file-effect Msg constructors. Exactly one is delivered
-/// per `readFile`/`writeFile` — terminal, nothing for that key after
+/// per one-shot file effect — terminal, nothing for that key after
/// it. `bytes` is a read's content (binary-safe), valid only during
/// the `update` call that receives it — copy what the model keeps.
/// Writes always deliver empty `bytes`.
@@ -664,7 +665,7 @@ pub const EffectFileResult = struct {
outcome: EffectFileOutcome = .ok,
/// Read contents: the whole file for `.ok`, the first
/// `max_effect_file_bytes` for `.truncated`, `""` otherwise (and
- /// always for writes).
+ /// always for writes and deletes).
bytes: []const u8 = "",
/// Stream total bytes on `.done`; stat size on a successful `.stat`.
total: u64 = 0,
@@ -3101,6 +3102,12 @@ pub fn Effects(comptime Msg: type) type {
on_result: ?FileMsgFn = null,
};
+ pub const DeleteFileOptions = struct {
+ key: u64,
+ path: []const u8,
+ on_result: ?FileMsgFn = null,
+ };
+
pub const ReadFileStreamOptions = struct {
key: u64,
path: []const u8,
@@ -6287,15 +6294,13 @@ pub fn Effects(comptime Msg: type) type {
if (self.file_access_binding == null) self.file_access_binding = binding;
}
- fn resolvedFileAccess(self: *Self, path: []const u8, create_parents: bool) ?file_access.Resolved {
+ fn resolvedFileAccess(self: *Self, path: []const u8, options: file_access.ResolveOptions) ?file_access.Resolved {
const binding = self.file_access_binding orelse return .{
.path = self.allocator.dupe(u8, path) catch return null,
.decision = .allow,
};
const io = self.ensureIo() catch return null;
- var resolved = file_access.resolveForOperation(self.allocator, io, binding, path, .{
- .create_parents = create_parents,
- }) catch return null;
+ var resolved = file_access.resolveForOperation(self.allocator, io, binding, path, options) catch return null;
return switch (resolved.decision) {
.allow => resolved,
.reject => blk: {
@@ -7043,7 +7048,7 @@ pub fn Effects(comptime Msg: type) type {
{
return self.rejectFile(options.key, .write, options.on_result);
}
- var access = self.resolvedFileAccess(options.path, true) orelse return self.rejectFileExternal(options.key, .write, options.on_result);
+ var access = self.resolvedFileAccess(options.path, .{ .create_parents = true }) orelse return self.rejectFileExternal(options.key, .write, options.on_result);
self.startFile(options.key, .write, &access, options.bytes, options.on_result);
}
@@ -7058,7 +7063,7 @@ pub fn Effects(comptime Msg: type) type {
if (options.path.len == 0 or options.path.len > max_effect_file_path_bytes) {
return self.rejectFile(options.key, .read, options.on_result);
}
- var access = self.resolvedFileAccess(options.path, false) orelse return self.rejectFileExternal(options.key, .read, options.on_result);
+ var access = self.resolvedFileAccess(options.path, .{}) orelse return self.rejectFileExternal(options.key, .read, options.on_result);
self.startFile(options.key, .read, &access, "", options.on_result);
}
@@ -7071,7 +7076,7 @@ pub fn Effects(comptime Msg: type) type {
{
return self.rejectFile(options.key, .append, options.on_result);
}
- var access = self.resolvedFileAccess(options.path, true) orelse return self.rejectFileExternal(options.key, .append, options.on_result);
+ var access = self.resolvedFileAccess(options.path, .{ .create_parents = true }) orelse return self.rejectFileExternal(options.key, .append, options.on_result);
self.startFile(options.key, .append, &access, options.bytes, options.on_result);
}
@@ -7081,10 +7086,23 @@ pub fn Effects(comptime Msg: type) type {
if (options.path.len == 0 or options.path.len > max_effect_file_path_bytes) {
return self.rejectFile(options.key, .stat, options.on_result);
}
- var access = self.resolvedFileAccess(options.path, false) orelse return self.rejectFileExternal(options.key, .stat, options.on_result);
+ var access = self.resolvedFileAccess(options.path, .{}) orelse return self.rejectFileExternal(options.key, .stat, options.on_result);
self.startFile(options.key, .stat, &access, "", options.on_result);
}
+ /// Delete one file. A final symlink is unlinked without deleting its
+ /// target. Absence is explicit (`.not_found`), while a directory or
+ /// another OS refusal is `.io_failed`. The operation uses the same
+ /// worker, key space, path policy, and exactly-one result callback as
+ /// the other one-shot file effects.
+ pub fn deleteFile(self: *Self, options: DeleteFileOptions) void {
+ if (options.path.len == 0 or options.path.len > max_effect_file_path_bytes) {
+ return self.rejectFile(options.key, .delete, options.on_result);
+ }
+ var access = self.resolvedFileAccess(options.path, .{ .preserve_final_component = true }) orelse return self.rejectFileExternal(options.key, .delete, options.on_result);
+ self.startFile(options.key, .delete, &access, "", options.on_result);
+ }
+
/// Stream a file as 256-KiB chunks followed by one `.done(total)`.
/// Reads own `max_effect_file_streams`; no whole-file allocation and
/// no total-size ceiling is involved.
@@ -7104,7 +7122,7 @@ pub fn Effects(comptime Msg: type) type {
{
return self.rejectFile(options.key, .read_stream, options.on_result);
}
- var access = self.resolvedFileAccess(options.path, false) orelse return self.rejectFileExternal(options.key, .read_stream, options.on_result);
+ var access = self.resolvedFileAccess(options.path, .{}) orelse return self.rejectFileExternal(options.key, .read_stream, options.on_result);
const access_io = self.ensureIo() catch unreachable;
defer access.deinit(self.allocator, access_io);
if (access.path.len > max_effect_file_path_bytes) return self.rejectFileExternal(options.key, .read_stream, options.on_result);
@@ -7142,7 +7160,7 @@ pub fn Effects(comptime Msg: type) type {
{
return self.rejectFile(options.key, .write_stream_open, options.on_result);
}
- var access = self.resolvedFileAccess(options.path, true) orelse return self.rejectFileExternal(options.key, .write_stream_open, options.on_result);
+ var access = self.resolvedFileAccess(options.path, .{ .create_parents = true }) orelse return self.rejectFileExternal(options.key, .write_stream_open, options.on_result);
const access_io = self.ensureIo() catch unreachable;
defer access.deinit(self.allocator, access_io);
if (access.path.len > max_effect_file_path_bytes) return self.rejectFileExternal(options.key, .write_stream_open, options.on_result);
@@ -7272,7 +7290,7 @@ pub fn Effects(comptime Msg: type) type {
const buffer_len = switch (op) {
.write, .append => bytes.len,
.read => max_effect_file_bytes + 1,
- .stat => 0,
+ .stat, .delete => 0,
else => unreachable,
};
const buffer = self.allocator.alloc(u8, buffer_len) catch {
@@ -16439,7 +16457,11 @@ pub fn Effects(comptime Msg: type) type {
};
defer file.close(io);
const stat = file.stat(io) catch |err| return fileOpFailure(err);
- file.writePositionalAll(io, ctx.payload(), stat.size) catch |err| return fileOpFailure(err);
+ if (comptime builtin.os.tag == .windows) {
+ appendFileWindows(file, io, ctx.payload()) catch |err| return fileOpFailure(err);
+ } else {
+ file.writePositionalAll(io, ctx.payload(), stat.size) catch |err| return fileOpFailure(err);
+ }
return .ok;
},
.read => {
@@ -16474,6 +16496,12 @@ pub fn Effects(comptime Msg: type) type {
ctx.stat_mtime_ms = @intCast(@divFloor(stat.mtime.nanoseconds, std.time.ns_per_ms));
return .ok;
},
+ .delete => {
+ dir.deleteFile(io, file_path) catch |err| {
+ return if (err == error.FileNotFound) .not_found else fileOpFailure(err);
+ };
+ return .ok;
+ },
else => unreachable,
}
}
@@ -16489,6 +16517,107 @@ pub fn Effects(comptime Msg: type) type {
};
}
+ /// Windows' Zig 0.16 threaded positional writer can surface
+ /// STATUS_PENDING for the asynchronous handle `follow_symlinks=false`
+ /// deliberately opens, while its streaming writer rejects the same
+ /// post-seek handle. NT defines `ByteOffset = -1` as the atomic
+ /// write-to-end sentinel. Give every write its own completion event:
+ /// neither the stack IOSB nor the caller's payload may die while the
+ /// kernel can still reach them. The handle is already exclusively
+ /// locked by the caller, and this loop preserves the
+ /// full-payload-or-error contract.
+ fn appendFileWindows(file: std.Io.File, io: std.Io, bytes: []const u8) !void {
+ if (comptime builtin.os.tag != .windows) unreachable;
+ const windows = std.os.windows;
+ var at: usize = 0;
+ while (at < bytes.len) {
+ var event: windows.HANDLE = undefined;
+ switch (windows.ntdll.NtCreateEvent(
+ &event,
+ .{
+ .SPECIFIC = .{ .EVENT = .{ .MODIFY_STATE = true } },
+ .STANDARD = .{ .SYNCHRONIZE = true },
+ },
+ null,
+ .Synchronization,
+ .FALSE,
+ )) {
+ .SUCCESS => {},
+ else => return error.InputOutput,
+ }
+ defer windows.CloseHandle(event);
+
+ var iosb: windows.IO_STATUS_BLOCK = .{
+ .u = .{ .Status = .PENDING },
+ .Information = 0,
+ };
+ const end_offset: windows.LARGE_INTEGER = -1;
+ const len: windows.ULONG = @intCast(@min(bytes.len - at, std.math.maxInt(windows.ULONG)));
+ const status = windows.ntdll.NtWriteFile(
+ file.handle,
+ event,
+ null,
+ null,
+ &iosb,
+ bytes[at..].ptr,
+ len,
+ &end_offset,
+ null,
+ );
+ if (status == .PENDING) {
+ wait: while (true) {
+ // A short kernel wait plus Io.checkCancel makes this
+ // direct NT call participate in the surrounding
+ // concurrent task's cancellation protocol without
+ // depending on Threaded's private alertable-syscall
+ // machinery.
+ const poll_interval_100ns: windows.LARGE_INTEGER = -50_000;
+ switch (windows.ntdll.NtWaitForSingleObject(event, .FALSE, &poll_interval_100ns)) {
+ windows.NTSTATUS.WAIT_0 => break :wait,
+ .TIMEOUT => std.Io.checkCancel(io) catch {
+ // The threaded Io cancels a concurrent task
+ // through this check. Cancel this exact
+ // request, then wait non-alertably until
+ // the kernel has dropped every reference to
+ // IOSB and payload before reporting it.
+ var cancel_iosb: windows.IO_STATUS_BLOCK = undefined;
+ _ = windows.ntdll.NtCancelIoFileEx(file.handle, &iosb, &cancel_iosb);
+ switch (windows.ntdll.NtWaitForSingleObject(event, .FALSE, null)) {
+ windows.NTSTATUS.WAIT_0 => {},
+ else => unreachable,
+ }
+ return error.Canceled;
+ },
+ else => {
+ var cancel_iosb: windows.IO_STATUS_BLOCK = undefined;
+ _ = windows.ntdll.NtCancelIoFileEx(file.handle, &iosb, &cancel_iosb);
+ switch (windows.ntdll.NtWaitForSingleObject(event, .FALSE, null)) {
+ windows.NTSTATUS.WAIT_0 => {},
+ else => unreachable,
+ }
+ return error.InputOutput;
+ },
+ }
+ }
+ } else {
+ // A failed immediate submission does not promise to fill
+ // the IOSB; make the returned status authoritative.
+ iosb.u.Status = status;
+ }
+ switch (iosb.u.Status) {
+ .SUCCESS => {},
+ .DISK_FULL => return error.NoSpaceLeft,
+ .ACCESS_DENIED => return error.AccessDenied,
+ .FILE_LOCK_CONFLICT => return error.LockViolation,
+ .CANCELLED => return error.Canceled,
+ .PENDING => return error.InputOutput,
+ else => return error.InputOutput,
+ }
+ if (iosb.Information == 0 or iosb.Information > len) return error.InputOutput;
+ at += iosb.Information;
+ }
+ }
+
fn openedFileIsInsideDir(io: std.Io, dir: std.Io.Dir, file: std.Io.File) bool {
var dir_path: [std.Io.Dir.max_path_bytes]u8 = undefined;
var file_path: [std.Io.Dir.max_path_bytes]u8 = undefined;
diff --git a/src/runtime/effects_file_tests.zig b/src/runtime/effects_file_tests.zig
index 3d36d9fd..6f7e36aa 100644
--- a/src/runtime/effects_file_tests.zig
+++ b/src/runtime/effects_file_tests.zig
@@ -61,6 +61,7 @@ const FileModel = struct {
const FileMsg = union(enum) {
save,
load,
+ delete,
stop,
file_result: effects_mod.EffectFileResult,
};
@@ -90,6 +91,11 @@ fn fileUpdate(model: *FileModel, msg: FileMsg, fx: *FileEffects) void {
.path = test_path,
.on_result = FileEffects.fileMsg(.file_result),
}),
+ .delete => fx.deleteFile(.{
+ .key = file_key,
+ .path = test_path,
+ .on_result = FileEffects.fileMsg(.file_result),
+ }),
.stop => fx.cancel(file_key),
.file_result => |result| {
model.record(result);
@@ -110,6 +116,7 @@ fn fileView(ui: *FileApp.Ui, model: *const FileModel) FileApp.Ui.Node {
ui.text(.{}, ui.fmt("{d} results", .{model.result_count})),
ui.button(.{ .on_press = .save }, "Save"),
ui.button(.{ .on_press = .load }, "Load"),
+ ui.button(.{ .on_press = .delete }, "Delete"),
ui.button(.{ .on_press = .stop }, "Stop"),
});
}
@@ -206,6 +213,42 @@ test "fake executor records file requests and feeds results back as msgs" {
try std.testing.expectEqual(@as(usize, 0), h.app_state.model.bytes_len);
}
+test "fake executor records delete requests and feeds the standard result callback" {
+ const Capture = struct {
+ var record: ?effects_mod.EffectResultRecord = null;
+
+ fn note(_: *anyopaque, value: effects_mod.EffectResultRecord) void {
+ record = value;
+ }
+ };
+
+ var h = try Harness.create();
+ defer h.destroy();
+ const fx = &h.app_state.effects;
+ fx.executor = .fake;
+ Capture.record = null;
+ var journal_context: u8 = 0;
+ fx.bindJournal(.{ .context = &journal_context, .record_fn = Capture.note });
+
+ test_path = "sessions/obsolete.json";
+ try h.app_state.dispatch(&h.harness.runtime, 1, .delete);
+ try std.testing.expectEqual(@as(usize, 1), fx.pendingFileCount());
+ const request = fx.pendingFileAt(0).?;
+ try std.testing.expectEqual(file_key, request.key);
+ try std.testing.expectEqual(effects_mod.EffectFileOp.delete, request.op);
+ try std.testing.expectEqualStrings("sessions/obsolete.json", request.path);
+ try std.testing.expectEqualStrings("", request.bytes);
+
+ try fx.feedFileResult(file_key, .ok, "ignored");
+ try h.harness.runtime.dispatchPlatformEvent(h.app, .wake);
+ try std.testing.expectEqual(effects_mod.EffectFileOp.delete, h.app_state.model.last_op.?);
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?);
+ try std.testing.expectEqual(@as(usize, 0), h.app_state.model.bytes_len);
+ try std.testing.expect(Capture.record != null);
+ try std.testing.expectEqual(effects_mod.EffectFileOp.delete, Capture.record.?.file_op);
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, Capture.record.?.file_outcome);
+}
+
test "fake reads over the file bound arrive cut with outcome truncated" {
var h = try Harness.create();
defer h.destroy();
@@ -413,6 +456,130 @@ test "real executor reports missing files as not_found" {
try std.testing.expectEqual(@as(usize, 0), h.app_state.model.bytes_len);
}
+test "real executor deletes a file and reports a later delete as not_found" {
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "obsolete.bin", .data = "remove me" });
+
+ var h = try Harness.create();
+ defer h.destroy();
+ var path_buffer: [256]u8 = undefined;
+ test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/obsolete.bin", .{tmp.sub_path[0..]});
+
+ try h.app_state.dispatch(&h.harness.runtime, 1, .delete);
+ try waitForRealResult(&h, 1);
+ try std.testing.expectEqual(effects_mod.EffectFileOp.delete, h.app_state.model.last_op.?);
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?);
+ try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "obsolete.bin", .{}));
+
+ try h.app_state.dispatch(&h.harness.runtime, 1, .delete);
+ try waitForRealResult(&h, 2);
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.not_found, h.app_state.model.last_outcome.?);
+}
+
+test "delete unlinks a final symlink without deleting its target" {
+ if (@import("builtin").os.tag == .windows) return error.SkipZigTest;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(io, "app-data");
+ try tmp.dir.writeFile(io, .{ .sub_path = "app-data/target.bin", .data = "keep me" });
+ try tmp.dir.symLink(io, "target.bin", "app-data/alias.bin", .{});
+
+ 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 root_buffer: [256]u8 = undefined;
+ var alias_buffer: [256]u8 = undefined;
+ const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app-data", .{tmp.sub_path[0..]});
+ const alias = try std.fmt.bufPrint(&alias_buffer, "{s}/alias.bin", .{root});
+ fx.bindFileAccess(.{ .roots = &.{root}, .permitted = false, .enforce = true });
+ fx.deleteFile(.{ .key = 1, .path = alias, .on_result = Fx.fileMsg(.result) });
+ const result = while (true) {
+ if (fx.takeMsg()) |msg| break msg.result;
+ try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
+ };
+
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, result.outcome);
+ try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "app-data/alias.bin", .{ .follow_symlinks = false }));
+ const target = try tmp.dir.readFileAlloc(io, "app-data/target.bin", std.testing.allocator, .limited(32));
+ defer std.testing.allocator.free(target);
+ try std.testing.expectEqualStrings("keep me", target);
+
+ try tmp.dir.symLink(io, "missing.bin", "app-data/dangling.bin", .{});
+ var dangling_buffer: [256]u8 = undefined;
+ const dangling = try std.fmt.bufPrint(&dangling_buffer, "{s}/dangling.bin", .{root});
+ fx.deleteFile(.{ .key = 2, .path = dangling, .on_result = Fx.fileMsg(.result) });
+ const dangling_result = while (true) {
+ if (fx.takeMsg()) |msg| break msg.result;
+ try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
+ };
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, dangling_result.outcome);
+ try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "app-data/dangling.bin", .{ .follow_symlinks = false }));
+}
+
+test "filesystem permission deletes an external symlink entry without following it" {
+ if (@import("builtin").os.tag == .windows) return error.SkipZigTest;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "target.bin", .data = "keep me too" });
+ try tmp.dir.symLink(io, "target.bin", "alias.bin", .{});
+
+ 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.bindFileAccess(.{ .roots = &.{}, .permitted = true, .enforce = true });
+
+ var alias_buffer: [256]u8 = undefined;
+ const alias = try std.fmt.bufPrint(&alias_buffer, ".zig-cache/tmp/{s}/alias.bin", .{tmp.sub_path[0..]});
+ fx.deleteFile(.{ .key = 1, .path = alias, .on_result = Fx.fileMsg(.result) });
+ const result = while (true) {
+ if (fx.takeMsg()) |msg| break msg.result;
+ try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
+ };
+
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, result.outcome);
+ try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "alias.bin", .{ .follow_symlinks = false }));
+ const target = try tmp.dir.readFileAlloc(io, "target.bin", std.testing.allocator, .limited(32));
+ defer std.testing.allocator.free(target);
+ try std.testing.expectEqualStrings("keep me too", target);
+}
+
+test "an app-root symlink to an external target still requires filesystem permission" {
+ if (@import("builtin").os.tag == .windows) return error.SkipZigTest;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(io, "app-data");
+ try tmp.dir.createDirPath(io, "outside");
+ try tmp.dir.writeFile(io, .{ .sub_path = "outside/target.bin", .data = "protected" });
+ try tmp.dir.symLink(io, "../outside/target.bin", "app-data/alias.bin", .{});
+
+ 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 root_buffer: [256]u8 = undefined;
+ var alias_buffer: [256]u8 = undefined;
+ const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app-data", .{tmp.sub_path[0..]});
+ const alias = try std.fmt.bufPrint(&alias_buffer, "{s}/alias.bin", .{root});
+ fx.bindFileAccess(.{ .roots = &.{root}, .permitted = false, .enforce = true });
+ fx.deleteFile(.{ .key = 1, .path = alias, .on_result = Fx.fileMsg(.result) });
+
+ const result = fx.takeMsg().?.result;
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, result.outcome);
+ _ = try tmp.dir.statFile(io, "app-data/alias.bin", .{ .follow_symlinks = false });
+ const target = try tmp.dir.readFileAlloc(io, "outside/target.bin", std.testing.allocator, .limited(32));
+ defer std.testing.allocator.free(target);
+ try std.testing.expectEqualStrings("protected", target);
+}
+
test "real executor cuts over-bound reads with outcome truncated" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
@@ -448,9 +615,17 @@ test "append and stat are bounded one-shot file effects" {
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);
+ const first = while (true) {
+ if (fx.takeMsg()) |msg| break msg.result;
+ try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
+ };
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, first.outcome);
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);
+ const second = while (true) {
+ if (fx.takeMsg()) |msg| break msg.result;
+ try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake);
+ };
+ try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, second.outcome);
fx.statFile(.{ .key = 3, .path = path, .on_result = Fx.fileMsg(.result) });
var stat_result: ?effects_mod.EffectFileResult = null;
while (stat_result == null) {
@@ -762,7 +937,7 @@ test "disk capacity errors are closed and enum-named across whole and streaming
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" {
+test "file access gating covers whole, append, stat, delete, and stream verbs without consuming slots" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(std.testing.io, "app-data");
@@ -790,9 +965,10 @@ test "file access gating covers whole, append, stat, and stream verbs without co
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 };
+ fx.deleteFile(.{ .key = 6, .path = outside, .on_result = Fx.fileMsg(.result) });
+ fx.readFileStream(.{ .key = 7, .path = outside, .on_result = Fx.fileMsg(.result) });
+ fx.writeFileStream(.{ .key = 8, .path = outside, .on_result = Fx.fileMsg(.result) });
+ const refused_ops = [_]effects_mod.EffectFileOp{ .read, .write, .append, .stat, .delete, .read_stream, .write_stream_open };
for (refused_ops) |expected_op| {
const refused = fx.takeMsg().?.result;
try std.testing.expectEqual(expected_op, refused.op);
@@ -805,10 +981,10 @@ test "file access gating covers whole, append, stat, and stream verbs without co
defer granted.deinit();
granted.executor = .fake;
granted.bindFileAccess(.{ .roots = &.{}, .permitted = true, .enforce = true });
- granted.statFile(.{ .key = 8, .path = outside, .on_result = Fx.fileMsg(.result) });
+ granted.statFile(.{ .key = 9, .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) });
+ granted.readFile(.{ .key = 10, .path = inside, .on_result = Fx.fileMsg(.result) });
+ granted.readFileStream(.{ .key = 11, .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
diff --git a/src/runtime/file_access.zig b/src/runtime/file_access.zig
index 1bcafef6..1ad3eac2 100644
--- a/src/runtime/file_access.zig
+++ b/src/runtime/file_access.zig
@@ -31,7 +31,7 @@ pub const Resolved = struct {
/// 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
+ /// A read/stat/delete 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,
@@ -45,11 +45,18 @@ pub const Resolved = struct {
pub const ResolveOptions = struct {
create_parents: bool = false,
+ /// Authorize the fully resolved target, but hand the caller the resolved
+ /// parent plus the request's original final component. Deletion uses this
+ /// so removing a symlink unlinks that directory entry rather than the file
+ /// it names. Both paths must be inside an app root when no filesystem
+ /// permission is present.
+ preserve_final_component: 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.
+/// Normalize first, then decide authority over that exact spelling. Most
+/// callers perform I/O against the resolved target. Operations that preserve
+/// the final component authorize both that target and the resolved directory
+/// entry they hand to the worker.
pub fn resolve(
allocator: std.mem.Allocator,
io: std.Io,
@@ -67,20 +74,41 @@ pub fn resolveForOperation(
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 };
+ const canonical_target = try canonicalizeTarget(allocator, io, requested_path);
+ if (!options.preserve_final_component) {
+ return resolveCanonicalOperation(allocator, io, binding, canonical_target, canonical_target, options);
+ }
+
+ defer allocator.free(canonical_target);
+ const operation_path = try canonicalizeFinalEntry(allocator, io, requested_path);
+ return resolveCanonicalOperation(allocator, io, binding, canonical_target, operation_path, options);
+}
+
+fn resolveCanonicalOperation(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ binding: Binding,
+ canonical_target: []const u8,
+ operation_path: []u8,
+ options: ResolveOptions,
+) Resolved {
+ if (binding.permitted) return .{ .path = operation_path, .decision = .allow };
+ if (options.preserve_final_component and
+ !canonicalIsInsideAnyRoot(allocator, io, binding.roots, canonical_target))
+ {
+ return .{ .path = operation_path, .decision = if (binding.enforce) .reject else .warn };
+ }
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
+ // A missing root can only authorize an absent read/stat/delete. 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 };
+ if (!options.create_parents and pathIsWithin(canonical_root, operation_path)) {
+ return .{ .path = operation_path, .decision = .allow, .missing = true };
}
continue;
};
@@ -91,12 +119,12 @@ pub fn resolveForOperation(
continue;
};
const root_real = root_real_storage[0..root_real_len];
- if (!pathIsWithin(root_real, canonical)) {
+ if (!pathIsWithin(root_real, operation_path)) {
root_dir.close(io);
continue;
}
- const relative = relativeToRoot(root_real, canonical) orelse {
+ const relative = relativeToRoot(root_real, operation_path) orelse {
root_dir.close(io);
continue;
};
@@ -107,20 +135,38 @@ pub fn resolveForOperation(
}
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 },
+ error.FileNotFound => return .{ .path = operation_path, .decision = .allow, .missing = true },
else => continue,
};
const basename_offset = @intFromPtr(basename.ptr) - @intFromPtr(relative.ptr) +
- (@intFromPtr(relative.ptr) - @intFromPtr(canonical.ptr));
+ (@intFromPtr(relative.ptr) - @intFromPtr(operation_path.ptr));
return .{
- .path = canonical,
+ .path = operation_path,
.decision = .allow,
.parent = parent,
- .basename = canonical[basename_offset .. basename_offset + basename.len],
+ .basename = operation_path[basename_offset .. basename_offset + basename.len],
};
}
- return .{ .path = canonical, .decision = if (binding.enforce) .reject else .warn };
+ return .{ .path = operation_path, .decision = if (binding.enforce) .reject else .warn };
+}
+
+/// Resolve every parent component but preserve the request's final directory
+/// entry. Unlike `canonicalizeTarget`, this deliberately does not follow a
+/// final symlink.
+fn canonicalizeFinalEntry(allocator: std.mem.Allocator, io: std.Io, requested_path: []const u8) ![]u8 {
+ const basename = std.fs.path.basename(requested_path);
+ if (basename.len == 0 or
+ std.mem.eql(u8, basename, ".") or
+ std.mem.eql(u8, basename, "..") or
+ std.mem.indexOfAny(u8, basename, if (@import("builtin").os.tag == .windows) "/\\" else "/") != null)
+ {
+ return error.InvalidPath;
+ }
+ const parent_path = std.fs.path.dirname(requested_path) orelse ".";
+ const canonical_parent = try canonicalizeTarget(allocator, io, parent_path);
+ defer allocator.free(canonical_parent);
+ return std.fs.path.resolve(allocator, &.{ canonical_parent, basename });
}
fn openCanonicalDir(io: std.Io, canonical_path: []const u8, create: bool) !std.Io.Dir {
diff --git a/src/runtime/session_journal.zig b/src/runtime/session_journal.zig
index 22f26072..26eb44fa 100644
--- a/src/runtime/session_journal.zig
+++ b/src/runtime/session_journal.zig
@@ -1716,6 +1716,17 @@ test "effect codec round-trips payloads and outcomes" {
try testing.expect(decoded.truncated);
try testing.expectEqual(@as(u16, 200), decoded.status);
+ const delete_encoded = try encodeEffect(.{
+ .kind = .file,
+ .key = 78,
+ .file_op = .delete,
+ .file_outcome = .not_found,
+ }, &buffer);
+ const delete_decoded = try decodeEffect(delete_encoded);
+ try testing.expectEqual(runtime_effects.EffectResultKind.file, delete_decoded.kind);
+ try testing.expectEqual(runtime_effects.EffectFileOp.delete, delete_decoded.file_op);
+ try testing.expectEqual(runtime_effects.EffectFileOutcome.not_found, delete_decoded.file_outcome);
+
const exit_encoded = try encodeEffect(.{
.kind = .exit,
.key = 5,
diff --git a/src/runtime/ts_core_host.zig b/src/runtime/ts_core_host.zig
index 869a7708..becd14fd 100644
--- a/src/runtime/ts_core_host.zig
+++ b/src/runtime/ts_core_host.zig
@@ -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` 6) onto the real effect engine
+//! emits (`cmd_format_version` 7) 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
@@ -1072,6 +1072,12 @@ pub fn TsCoreHost(comptime core: type) type {
const effect_index = allocEffectEntry(fx, head) orelse continue;
fx.statFile(.{ .key = effect_key_base + effect_index, .path = file_path, .on_result = fileResultMsg });
},
+ 0x32 => {
+ const head = takeRoutedHead(cmd, &at);
+ const file_path = takeLongBytes(cmd, &at);
+ const effect_index = allocEffectEntry(fx, head) orelse continue;
+ fx.deleteFile(.{ .key = effect_key_base + effect_index, .path = file_path, .on_result = fileResultMsg });
+ },
0x2D => {
const key = takeShortBytes(cmd, &at);
const chunk_tag = takeByte(cmd, &at);
diff --git a/tests/sidecar/integer_fixture.contract.json b/tests/sidecar/integer_fixture.contract.json
index 89df56a3..ae625a10 100644
--- a/tests/sidecar/integer_fixture.contract.json
+++ b/tests/sidecar/integer_fixture.contract.json
@@ -1,6 +1,6 @@
{
"format": 1,
- "wire_version": 6,
+ "wire_version": 7,
"abi_version": 2,
"compiler_version": "0.0.1",
"entry": "tests/sidecar/integer_fixture.ts",
diff --git a/tests/sidecar/markup_fixture.contract.json b/tests/sidecar/markup_fixture.contract.json
index b19fb3fa..57f8db45 100644
--- a/tests/sidecar/markup_fixture.contract.json
+++ b/tests/sidecar/markup_fixture.contract.json
@@ -1,6 +1,6 @@
{
"format": 1,
- "wire_version": 6,
+ "wire_version": 7,
"abi_version": 2,
"compiler_version": "0.0.1",
"entry": "tests/ts-core/markup_fixture.ts",
diff --git a/tests/ts-core/fixture.ts b/tests/ts-core/fixture.ts
index 592c8936..eedc51e0 100644
--- a/tests/ts-core/fixture.ts
+++ b/tests/ts-core/fixture.ts
@@ -114,6 +114,7 @@ export type Msg =
| { readonly kind: "file_stat"; readonly exists: boolean; readonly size: number; readonly mtimeMs: number }
| { readonly kind: "stat_file" }
| { readonly kind: "append_file" }
+ | { readonly kind: "delete_file" }
| { readonly kind: "stream_read" }
| { readonly kind: "stream_open" }
| { readonly kind: "stream_chunk" }
@@ -268,6 +269,8 @@ export function update(model: Model, msg: Msg): [Model, Cmd] {
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 "delete_file":
+ return [model, Cmd.deleteFile(asciiBytes(".zig-cache/tmp/ts-core-tier5/append.bin"), { 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":
diff --git a/tests/ts-core/host_e2e_tests.zig b/tests/ts-core/host_e2e_tests.zig
index 3396af9c..9d6fb123 100644
--- a/tests/ts-core/host_e2e_tests.zig
+++ b/tests/ts-core/host_e2e_tests.zig
@@ -67,6 +67,7 @@ fn e2eCommand(name: []const u8) ?fixture.Msg {
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.filedelete")) return .delete_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;
@@ -643,6 +644,15 @@ test "compiled stat, append, and file-stream verbs route through the runtime" {
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);
+
+ try h.menu("core.filedelete");
+ try h.waitPending();
+ try h.wake();
+ try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().statFile(io, tier5_append_path, .{}));
+ try h.menu("core.filedelete");
+ try h.waitPending();
+ try h.wake();
+ try std.testing.expectEqualStrings("not_found", Bridge.model().lastErr);
}
test "every Cmd.store factory emits its bounded v3 record through the external core" {
diff --git a/tools/corewire/emit.zig b/tools/corewire/emit.zig
index 099f3d7c..9064e2ae 100644
--- a/tools/corewire/emit.zig
+++ b/tools/corewire/emit.zig
@@ -1764,7 +1764,7 @@ test "a u64 attestation on chrome geometry refuses at check time" {
// class cannot carry.
const source =
\\{
- \\ "format": 1, "wire_version": 6, "abi_version": 2,
+ \\ "format": 1, "wire_version": 7, "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": 6, "abi_version": 2,
+ \\ "format": 1, "wire_version": 7, "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": 6, "abi_version": 2,
+ \\ "format": 1, "wire_version": 7, "abi_version": 2,
\\ "compiler_version": "0.0.1", "entry": "src/core.ts",
\\ "source_hash": "00000000c0ffee00", "build_id": "00000000b01dface", "model_fingerprint": "00000000a11ce001",
\\ "types": {
diff --git a/tools/corewire/emit_facade.zig b/tools/corewire/emit_facade.zig
index 8929cdb0..c7942645 100644
--- a/tools/corewire/emit_facade.zig
+++ b/tools/corewire/emit_facade.zig
@@ -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 6). nscfTagOf maps a Msg arm name onto its
+ \\// (cmd_format_version 7). nscfTagOf maps a Msg arm name onto its
\\// declaration-order wire tag.
\\
\\const nscfFetchMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"];
@@ -2787,6 +2787,13 @@ const FacadeEmitter = struct {
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
\\ nscfWBytes(sink, cmd.path);
\\ return;
+ \\ case "delete_file":
+ \\ nscfWU8(sink, 0x32);
+ \\ 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);
diff --git a/tools/corewire/emit_profile.zig b/tools/corewire/emit_profile.zig
index 0c1b01dc..8abd5185 100644
--- a/tools/corewire/emit_profile.zig
+++ b/tools/corewire/emit_profile.zig
@@ -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\": 6") != null);
+ try testing.expect(std.mem.indexOf(u8, first, "\"wire_version\": 7") != 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\": 6", "\"wire_version\": 6");
+ source = try std.mem.replaceOwned(u8, arena, source, "\"wire_version\": 7", "\"wire_version\": 7");
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);
diff --git a/tools/corewire/sidecar.zig b/tools/corewire/sidecar.zig
index fe7568d1..6d08723a 100644
--- a/tools/corewire/sidecar.zig
+++ b/tools/corewire/sidecar.zig
@@ -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 = 6;
+pub const supported_wire_version: i64 = 7;
/// 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": 6,
+ \\ "wire_version": 7,
\\ "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\": 6", "\"wire_version\": 7");
- try expectRefusal(source, "wire_version", "generation 6, the sidecar declares 7");
+ const source = try replaced(arena_state.allocator(), minimal_valid_json, "\"wire_version\": 7", "\"wire_version\": 8");
+ try expectRefusal(source, "wire_version", "generation 7, the sidecar declares 8");
}
test "unknown fields warn and are ignored" {