@hew-lang/sandbox-vm 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -4
- package/cli/test-runner.mjs +4 -1
- package/dist/interpreter/interpreter.d.ts +1 -1
- package/dist/interpreter/interpreter.js +1 -1
- package/dist/interpreter/parity-runner.js +1 -1
- package/dist/interpreter/run-program.d.ts +3 -2
- package/dist/interpreter/run-program.js +5 -3
- package/dist/interpreter/schema-validator.d.ts +9 -4
- package/dist/interpreter/schema-validator.js +15 -49
- package/dist/interpreter/types.d.ts +4 -0
- package/dist/interpreter/v1/browser-directory.d.ts +107 -0
- package/dist/interpreter/v1/browser-directory.js +288 -0
- package/dist/interpreter/v1/calendar.d.ts +4 -0
- package/dist/interpreter/v1/calendar.js +64 -0
- package/dist/interpreter/v1/codecs.js +2 -2
- package/dist/interpreter/v1/exec.d.ts +2 -2
- package/dist/interpreter/v1/exec.js +439 -183
- package/dist/interpreter/v1/instructions.generated.d.ts +1135 -0
- package/dist/interpreter/v1/instructions.generated.js +106 -0
- package/dist/interpreter/v1/package-graph.d.ts +2 -0
- package/dist/interpreter/v1/package-graph.js +729 -0
- package/dist/interpreter/v1/package-validator.generated.d.ts +3 -0
- package/dist/interpreter/v1/package-validator.generated.js +75902 -0
- package/dist/interpreter/v1/package.d.ts +5 -680
- package/dist/interpreter/v1/package.js +0 -12
- package/dist/interpreter/v1/scheduler.d.ts +5 -1
- package/dist/interpreter/v1/scheduler.js +32 -3
- package/dist/interpreter/v1/shims.d.ts +250 -7
- package/dist/interpreter/v1/shims.js +430 -150
- package/dist/interpreter/v1/structural.d.ts +2 -28
- package/dist/interpreter/v1/timestamp.d.ts +7 -0
- package/dist/interpreter/v1/timestamp.js +54 -0
- package/dist/interpreter/v1/validate.d.ts +8 -2
- package/dist/interpreter/v1/validate.js +190 -202
- package/dist/interpreter/v1/value-operations.d.ts +7 -2
- package/dist/interpreter/v1/value-operations.js +13 -6
- package/dist/interpreter/values.d.ts +4 -0
- package/dist/interpreter/values.js +6 -0
- package/dist/interpreter/worker.js +4 -3
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
borrows, cleanup, checked failures, actor protocols and suspension edges.
|
|
6
6
|
|
|
7
7
|
Actors, tasks and pipes use resumable frames on a deterministic scheduler.
|
|
8
|
-
Timers advance virtual time.
|
|
9
|
-
|
|
8
|
+
Timers advance virtual time. Hosts can supply real origin-private filesystem
|
|
9
|
+
(OPFS) directory handles; network and ambient filesystem operations are refused
|
|
10
|
+
before execution. Other missing operations are reported separately as
|
|
10
11
|
`not_implemented`. The loader is the capability admission authority.
|
|
11
12
|
|
|
12
13
|
## Browser API
|
|
@@ -17,16 +18,43 @@ import { runBytecode } from "@hew-lang/sandbox-vm";
|
|
|
17
18
|
|
|
18
19
|
const { diagnostics, bytecode } = JSON.parse(compileToSandboxBytecode(source));
|
|
19
20
|
if (bytecode) {
|
|
20
|
-
const trace = runBytecode(bytecode, {
|
|
21
|
+
const trace = await runBytecode(bytecode, {
|
|
22
|
+
replay: { step_budget: 100000 },
|
|
23
|
+
});
|
|
21
24
|
}
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
Initialize the Wasm compiler using its generated default export before calling
|
|
25
28
|
it. `runProgram(source, stdin)` is also available for existing page bridges;
|
|
26
|
-
install `globalThis.compileToSandboxBytecode` first.
|
|
29
|
+
install `globalThis.compileToSandboxBytecode` first. Both entrypoints return a Promise. The page result includes
|
|
27
30
|
`stdout`, `exit_code`, `diagnostics`, `status`, `sandbox_rejections`,
|
|
28
31
|
`compiler_version` and `hew_version`.
|
|
29
32
|
|
|
33
|
+
To grant filesystem authority, pass only the directories the program needs:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const trace = await runBytecode(bytecode, {
|
|
37
|
+
directoryRoots: { read: inputDirectory, write: outputDirectory },
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Both roots are optional and default to no authority. The handles must belong to
|
|
43
|
+
this origin's OPFS; external picker-backed handles are refused because their
|
|
44
|
+
symlink confinement is not standardized. Existing Hew `fs.open_read_dir(".")`
|
|
45
|
+
and `fs.open_write_dir(".")` acquire the supplied roots. Relative traversal stays
|
|
46
|
+
beneath the current directory owner, including after narrowing with `subdir`.
|
|
47
|
+
Children remain usable after closing a parent. Each `file.write` publishes its
|
|
48
|
+
bytes before it returns, even while the file owner remains open.
|
|
49
|
+
|
|
50
|
+
`AbortSignal` requests cancellation through the source's authored cleanup edges.
|
|
51
|
+
Filesystem acquisition publishes its owner before observing cancellation, as on
|
|
52
|
+
native hosts. An in-progress file operation drains or aborts its browser writer
|
|
53
|
+
before cancellation releases its inputs. Pure execution retains the deterministic
|
|
54
|
+
virtual scheduler and runs until a host wait or completion. Pass the same options
|
|
55
|
+
as the fourth argument to `runProgram(source, stdin, files, options)`; signals are
|
|
56
|
+
for direct calls rather than worker message payloads.
|
|
57
|
+
|
|
30
58
|
A `sandbox_rejected` result includes rejection entries with `category`, `code`,
|
|
31
59
|
`capability`, `message` and `span`. Categories are `native_only`,
|
|
32
60
|
`not_implemented` and `invalid_package`. Compiler errors, language panics,
|
|
@@ -48,6 +76,13 @@ The [package contract](bytecode/package-v1.md) describes the SIR projection.
|
|
|
48
76
|
Source fixtures are compiled by the public compiler and executed to record
|
|
49
77
|
deterministic traces. Tests also compare executable sources with native Hew.
|
|
50
78
|
|
|
79
|
+
With `hew-wasm/pkg` and `hew-sandbox-vm/dist` built, serve the repository on
|
|
80
|
+
localhost and open `hew-sandbox-vm/test/browser/directory-handles.html` to run the
|
|
81
|
+
real browser filesystem controls. They compile the ordinary Hew source fixture,
|
|
82
|
+
use a temporary OPFS directory, verify disk contents, confinement and cancellation,
|
|
83
|
+
then remove their entries. The page accepts a `compiler` module URL query parameter
|
|
84
|
+
to reuse another built compiler package.
|
|
85
|
+
|
|
51
86
|
`RunOptions.argv` supplies process arguments, including the executable name at
|
|
52
87
|
index zero. It defaults to `["main.hew"]` and is copied for each run. For example,
|
|
53
88
|
`runBytecode(bytecode, { argv: ["greet", "--", "Zoë", ""] })` supplies those exact
|
|
@@ -58,3 +93,7 @@ Pass its initialized `executeCodec` export as `runBytecode`'s `codecBridge`
|
|
|
58
93
|
option. The Node test host loads that package automatically; in a source
|
|
59
94
|
checkout, run `make wasm` to build `hew-wasm/pkg` first. An explicit
|
|
60
95
|
`HEW_WASM_MODULE` module URL can select another build of the same package.
|
|
96
|
+
|
|
97
|
+
UTC calendar operations also require the initialized `executeCalendar`
|
|
98
|
+
export as `calendarBridge`. Both hosts use the shared Chrono calendar engine;
|
|
99
|
+
the bridge preserves integer seconds and nanoseconds exactly.
|
package/cli/test-runner.mjs
CHANGED
|
@@ -22,6 +22,7 @@ try {
|
|
|
22
22
|
const args = parseArgs(process.argv.slice(2));
|
|
23
23
|
const packageValue = JSON.parse(fs.readFileSync(args.path, "utf8"));
|
|
24
24
|
let codecBridge;
|
|
25
|
+
let calendarBridge;
|
|
25
26
|
if (packageValue.codecs?.length) {
|
|
26
27
|
const local = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../hew-wasm/pkg/hew_wasm.js");
|
|
27
28
|
const location = process.env.HEW_WASM_MODULE ?? (fs.existsSync(local) ? pathToFileURL(local).href : "@hew-lang/wasm");
|
|
@@ -31,11 +32,13 @@ try {
|
|
|
31
32
|
await wasm.default({ module_or_path: fs.readFileSync(new URL(resolved.replace(/\.js$/, "_bg.wasm"))) });
|
|
32
33
|
}
|
|
33
34
|
codecBridge = wasm.executeCodec ?? wasm.default?.executeCodec;
|
|
35
|
+
calendarBridge = wasm.executeCalendar ?? wasm.default?.executeCalendar;
|
|
34
36
|
if (typeof codecBridge !== "function") throw new Error("hew-wasm executeCodec export is missing; rebuild the Wasm package");
|
|
35
37
|
}
|
|
36
38
|
const propertyPath = reportPath ? path.join(path.dirname(reportPath), "property.cbor") : null;
|
|
37
|
-
const trace = runBytecode(packageValue, {
|
|
39
|
+
const trace = await runBytecode(packageValue, {
|
|
38
40
|
codecBridge,
|
|
41
|
+
calendarBridge,
|
|
39
42
|
propertyInput: propertyPath && fs.existsSync(propertyPath) ? [...fs.readFileSync(propertyPath)] : [],
|
|
40
43
|
fixtureId: "hew-test",
|
|
41
44
|
traceId: "trace:hew-test",
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { RunOptions, SandboxTrace } from "./types.js";
|
|
2
|
-
export declare function runBytecode(input: unknown, options?: RunOptions): SandboxTrace
|
|
2
|
+
export declare function runBytecode(input: unknown, options?: RunOptions): Promise<SandboxTrace>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateBytecodePackage } from "./schema-validator.js";
|
|
2
2
|
import { runPackageV1 } from "./v1/exec.js";
|
|
3
3
|
import { TraceBuilder } from "./trace.js";
|
|
4
|
-
export function runBytecode(input, options = {}) {
|
|
4
|
+
export async function runBytecode(input, options = {}) {
|
|
5
5
|
let bytecode;
|
|
6
6
|
try {
|
|
7
7
|
bytecode = validateBytecodePackage(input);
|
|
@@ -45,7 +45,7 @@ function parseArgs(argv) {
|
|
|
45
45
|
try {
|
|
46
46
|
const args = parseArgs(process.argv.slice(2));
|
|
47
47
|
const bytecode = JSON.parse(fs.readFileSync(args.bytecodePath, "utf8"));
|
|
48
|
-
const trace = runBytecode(bytecode, {
|
|
48
|
+
const trace = await runBytecode(bytecode, {
|
|
49
49
|
replay: {
|
|
50
50
|
seed: args.seed,
|
|
51
51
|
step_budget: DEFAULT_STEP_BUDGET,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PackageV1 } from "./v1/package.js";
|
|
2
|
-
import type { JsonValue, RuntimeStatus, SandboxRejection } from "./types.js";
|
|
2
|
+
import type { RunOptions, JsonValue, RuntimeStatus, SandboxRejection } from "./types.js";
|
|
3
3
|
export interface Diagnostic {
|
|
4
4
|
severity: string;
|
|
5
5
|
phase: string;
|
|
@@ -24,7 +24,8 @@ type SandboxCompiler = (source: string, profile?: string, files?: Map<string, st
|
|
|
24
24
|
declare global {
|
|
25
25
|
var __hewSandboxCompileToSandboxBytecode: SandboxCompiler | undefined;
|
|
26
26
|
var __hewSandboxExecuteCodec: ((request: string) => string) | undefined;
|
|
27
|
+
var __hewSandboxExecuteCalendar: ((request: string) => string) | undefined;
|
|
27
28
|
var compileToSandboxBytecode: SandboxCompiler | undefined;
|
|
28
29
|
}
|
|
29
|
-
export declare function runProgram(source: string, stdin: string, files?: Map<string, string | Uint8Array
|
|
30
|
+
export declare function runProgram(source: string, stdin: string, files?: Map<string, string | Uint8Array>, options?: RunOptions): Promise<RunProgramResult>;
|
|
30
31
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runBytecode } from "./interpreter.js";
|
|
2
2
|
const SANDBOX_PROFILE = "sandbox-vm-export";
|
|
3
|
-
export function runProgram(source, stdin, files) {
|
|
3
|
+
export async function runProgram(source, stdin, files, options = {}) {
|
|
4
4
|
const compileOutput = compileSource(source, files);
|
|
5
5
|
if (hasErrorDiagnostics(compileOutput.diagnostics) ||
|
|
6
6
|
compileOutput.bytecode === null) {
|
|
@@ -15,9 +15,11 @@ export function runProgram(source, stdin, files) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
const bytecode = compileOutput.bytecode;
|
|
18
|
-
const trace = runBytecode(bytecode, {
|
|
18
|
+
const trace = await runBytecode(bytecode, {
|
|
19
|
+
...options,
|
|
19
20
|
fixtureId: "page-run",
|
|
20
|
-
codecBridge: globalThis.__hewSandboxExecuteCodec,
|
|
21
|
+
codecBridge: options.codecBridge ?? globalThis.__hewSandboxExecuteCodec,
|
|
22
|
+
calendarBridge: options.calendarBridge ?? globalThis.__hewSandboxExecuteCalendar,
|
|
21
23
|
traceId: "trace:page-run",
|
|
22
24
|
replay: { inputs: [{ kind: "stdin", data: stdin }] },
|
|
23
25
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PackageV1 } from "./v1/package.js";
|
|
2
|
+
declare const checkedPackage: unique symbol;
|
|
3
|
+
export type CheckedPackage = PackageV1 & {
|
|
4
|
+
readonly [checkedPackage]: true;
|
|
5
|
+
};
|
|
2
6
|
export declare class BytecodeValidationError extends Error {
|
|
3
7
|
readonly errors: string[];
|
|
4
8
|
constructor(errors: string[]);
|
|
5
9
|
}
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
export declare function validateBytecodePackage(input: unknown):
|
|
10
|
+
/** Decode an isolated package before capability admission or any host effect.
|
|
11
|
+
* Cloning prevents a caller or host callback from changing the checked input. */
|
|
12
|
+
export declare function validateBytecodePackage(input: unknown): CheckedPackage;
|
|
13
|
+
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { validPackage } from "./v1/package-validator.generated.js";
|
|
2
|
+
import { validatePackageGraph } from "./v1/package-graph.js";
|
|
2
3
|
export class BytecodeValidationError extends Error {
|
|
3
4
|
errors;
|
|
4
5
|
constructor(errors) {
|
|
@@ -7,55 +8,20 @@ export class BytecodeValidationError extends Error {
|
|
|
7
8
|
this.name = "BytecodeValidationError";
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
11
|
+
/** Decode an isolated package before capability admission or any host effect.
|
|
12
|
+
* Cloning prevents a caller or host callback from changing the checked input. */
|
|
12
13
|
export function validateBytecodePackage(input) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
errors.push(`${key} must be a string`);
|
|
19
|
-
}
|
|
20
|
-
for (const key of [
|
|
21
|
-
"functions",
|
|
22
|
-
"strings",
|
|
23
|
-
"bytes",
|
|
24
|
-
"regex_patterns",
|
|
25
|
-
"aggregates",
|
|
26
|
-
"variants",
|
|
27
|
-
"runtime_families",
|
|
28
|
-
"externs",
|
|
29
|
-
"suspend_kinds",
|
|
30
|
-
"value_capabilities",
|
|
31
|
-
"closures",
|
|
32
|
-
"vtables",
|
|
33
|
-
]) {
|
|
34
|
-
if (!Array.isArray(input[key]))
|
|
35
|
-
errors.push(`${key} must be an array`);
|
|
36
|
-
}
|
|
37
|
-
if (errors.length)
|
|
38
|
-
throw new BytecodeValidationError(errors);
|
|
39
|
-
for (const fn of input.functions) {
|
|
40
|
-
if (!fn ||
|
|
41
|
-
!Number.isInteger(fn.id) ||
|
|
42
|
-
!Array.isArray(fn.params) ||
|
|
43
|
-
!Array.isArray(fn.places) ||
|
|
44
|
-
!Array.isArray(fn.blocks)) {
|
|
45
|
-
errors.push("malformed function");
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
for (const block of fn.blocks) {
|
|
49
|
-
if (!block ||
|
|
50
|
-
!Number.isInteger(block.id) ||
|
|
51
|
-
!Array.isArray(block.params) ||
|
|
52
|
-
!Array.isArray(block.ops) ||
|
|
53
|
-
!block.term ||
|
|
54
|
-
typeof block.term.op !== "string")
|
|
55
|
-
errors.push("malformed block");
|
|
14
|
+
try {
|
|
15
|
+
const decoded = structuredClone(input);
|
|
16
|
+
if (!validPackage(decoded)) {
|
|
17
|
+
const reasons = (validPackage.errors ?? []).map((issue) => `${issue.instancePath || "/"} ${issue.message ?? "invalid value"}`);
|
|
18
|
+
throw new Error(reasons.join("; ") || "malformed sandbox bytecode package");
|
|
56
19
|
}
|
|
20
|
+
const pkg = decoded;
|
|
21
|
+
validatePackageGraph(pkg);
|
|
22
|
+
return pkg;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new BytecodeValidationError([String(error)]);
|
|
57
26
|
}
|
|
58
|
-
if (errors.length)
|
|
59
|
-
throw new BytecodeValidationError(errors);
|
|
60
|
-
return input;
|
|
61
27
|
}
|
|
@@ -117,9 +117,13 @@ export interface SandboxTrace {
|
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
119
|
export interface RunOptions {
|
|
120
|
+
/** Explicit browser filesystem authority; absent roots grant no access. */
|
|
121
|
+
directoryRoots?: import("./v1/browser-directory.js").DirectoryRoots;
|
|
122
|
+
signal?: AbortSignal;
|
|
120
123
|
/** Supplied process arguments including argv[0]. Defaults to ["main.hew"]. */
|
|
121
124
|
argv?: readonly string[];
|
|
122
125
|
codecBridge?: (request: string) => string;
|
|
126
|
+
calendarBridge?: (request: string) => string;
|
|
123
127
|
propertyInput?: number[];
|
|
124
128
|
fixtureId?: string;
|
|
125
129
|
traceId?: string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type VmValue } from "../values.js";
|
|
2
|
+
/** Authority comes only from the embedding host, never from a path or cwd. */
|
|
3
|
+
export interface DirectoryRoots {
|
|
4
|
+
read?: FileSystemDirectoryHandle;
|
|
5
|
+
write?: FileSystemDirectoryHandle;
|
|
6
|
+
}
|
|
7
|
+
export type BrowserFileOwner = {
|
|
8
|
+
access: "read" | "write";
|
|
9
|
+
handle: FileSystemFileHandle | null;
|
|
10
|
+
cursor: number;
|
|
11
|
+
closed: boolean;
|
|
12
|
+
tail: Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export type BrowserDirectoryOwner = {
|
|
15
|
+
access: "read" | "write";
|
|
16
|
+
handle: FileSystemDirectoryHandle | null;
|
|
17
|
+
closed: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type BrowserFsOwner = BrowserFileOwner | BrowserDirectoryOwner;
|
|
20
|
+
export interface BrowserIoError {
|
|
21
|
+
kind: number;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
export interface BrowserIoResult {
|
|
25
|
+
value: VmValue;
|
|
26
|
+
error: BrowserIoError | null;
|
|
27
|
+
}
|
|
28
|
+
export declare const DIRECTORY_OPERATIONS: {
|
|
29
|
+
readonly hew_dir_open_read: {
|
|
30
|
+
readonly release: "hew_dir_read_close";
|
|
31
|
+
readonly action: "root";
|
|
32
|
+
readonly access: "read";
|
|
33
|
+
};
|
|
34
|
+
readonly hew_dir_open_write: {
|
|
35
|
+
readonly release: "hew_dir_write_close";
|
|
36
|
+
readonly action: "root";
|
|
37
|
+
readonly access: "write";
|
|
38
|
+
};
|
|
39
|
+
readonly hew_dir_read_subdir: {
|
|
40
|
+
readonly release: "hew_dir_read_close";
|
|
41
|
+
readonly action: "subdir";
|
|
42
|
+
readonly access: "read";
|
|
43
|
+
};
|
|
44
|
+
readonly hew_dir_write_subdir: {
|
|
45
|
+
readonly release: "hew_dir_write_close";
|
|
46
|
+
readonly action: "subdir";
|
|
47
|
+
readonly access: "write";
|
|
48
|
+
};
|
|
49
|
+
readonly hew_dir_read_open_file: {
|
|
50
|
+
readonly release: "hew_file_read_close";
|
|
51
|
+
readonly action: "file";
|
|
52
|
+
readonly access: "read";
|
|
53
|
+
};
|
|
54
|
+
readonly hew_dir_write_open_file: {
|
|
55
|
+
readonly release: "hew_file_write_close";
|
|
56
|
+
readonly action: "file";
|
|
57
|
+
readonly access: "write";
|
|
58
|
+
};
|
|
59
|
+
readonly hew_dir_read_is_valid: {
|
|
60
|
+
readonly action: "valid";
|
|
61
|
+
readonly access: "read";
|
|
62
|
+
};
|
|
63
|
+
readonly hew_dir_write_is_valid: {
|
|
64
|
+
readonly action: "valid";
|
|
65
|
+
readonly access: "write";
|
|
66
|
+
};
|
|
67
|
+
readonly hew_file_read_is_valid: {
|
|
68
|
+
readonly action: "valid";
|
|
69
|
+
readonly access: "read";
|
|
70
|
+
};
|
|
71
|
+
readonly hew_file_write_is_valid: {
|
|
72
|
+
readonly action: "valid";
|
|
73
|
+
readonly access: "write";
|
|
74
|
+
};
|
|
75
|
+
readonly hew_dir_read_close: {
|
|
76
|
+
readonly action: "close";
|
|
77
|
+
readonly access: "read";
|
|
78
|
+
};
|
|
79
|
+
readonly hew_dir_write_close: {
|
|
80
|
+
readonly action: "close";
|
|
81
|
+
readonly access: "write";
|
|
82
|
+
};
|
|
83
|
+
readonly hew_file_read_close: {
|
|
84
|
+
readonly action: "close";
|
|
85
|
+
readonly access: "read";
|
|
86
|
+
};
|
|
87
|
+
readonly hew_file_write_close: {
|
|
88
|
+
readonly action: "close";
|
|
89
|
+
readonly access: "write";
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
export type DirectoryOperation = (typeof DIRECTORY_OPERATIONS)[keyof typeof DIRECTORY_OPERATIONS];
|
|
93
|
+
export declare class BrowserDirectories {
|
|
94
|
+
private readonly roots;
|
|
95
|
+
private readonly owners;
|
|
96
|
+
retire(release: (value: Extract<VmValue, {
|
|
97
|
+
kind: "host_resource";
|
|
98
|
+
}>) => void): void;
|
|
99
|
+
/** OPFS has no symlink entries. Picker-backed filesystem link confinement is
|
|
100
|
+
* not standardized, so this adapter refuses those handles before execution. */
|
|
101
|
+
static admission(roots: DirectoryRoots | undefined): Promise<string | null>;
|
|
102
|
+
constructor(roots?: DirectoryRoots);
|
|
103
|
+
call(operation: DirectoryOperation, args: VmValue[], resource?: number | null): VmValue | Promise<BrowserIoResult>;
|
|
104
|
+
private acquire;
|
|
105
|
+
private owned;
|
|
106
|
+
io(operation: "FileRead" | "FileWrite", args: VmValue[], cancelled: () => boolean): Promise<BrowserIoResult>;
|
|
107
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { UNIT } from "../values.js";
|
|
2
|
+
export const DIRECTORY_OPERATIONS = {
|
|
3
|
+
hew_dir_open_read: {
|
|
4
|
+
release: "hew_dir_read_close",
|
|
5
|
+
action: "root",
|
|
6
|
+
access: "read",
|
|
7
|
+
},
|
|
8
|
+
hew_dir_open_write: {
|
|
9
|
+
release: "hew_dir_write_close",
|
|
10
|
+
action: "root",
|
|
11
|
+
access: "write",
|
|
12
|
+
},
|
|
13
|
+
hew_dir_read_subdir: {
|
|
14
|
+
release: "hew_dir_read_close",
|
|
15
|
+
action: "subdir",
|
|
16
|
+
access: "read",
|
|
17
|
+
},
|
|
18
|
+
hew_dir_write_subdir: {
|
|
19
|
+
release: "hew_dir_write_close",
|
|
20
|
+
action: "subdir",
|
|
21
|
+
access: "write",
|
|
22
|
+
},
|
|
23
|
+
hew_dir_read_open_file: {
|
|
24
|
+
release: "hew_file_read_close",
|
|
25
|
+
action: "file",
|
|
26
|
+
access: "read",
|
|
27
|
+
},
|
|
28
|
+
hew_dir_write_open_file: {
|
|
29
|
+
release: "hew_file_write_close",
|
|
30
|
+
action: "file",
|
|
31
|
+
access: "write",
|
|
32
|
+
},
|
|
33
|
+
hew_dir_read_is_valid: { action: "valid", access: "read" },
|
|
34
|
+
hew_dir_write_is_valid: { action: "valid", access: "write" },
|
|
35
|
+
hew_file_read_is_valid: { action: "valid", access: "read" },
|
|
36
|
+
hew_file_write_is_valid: { action: "valid", access: "write" },
|
|
37
|
+
hew_dir_read_close: { action: "close", access: "read" },
|
|
38
|
+
hew_dir_write_close: { action: "close", access: "write" },
|
|
39
|
+
hew_file_read_close: { action: "close", access: "read" },
|
|
40
|
+
hew_file_write_close: { action: "close", access: "write" },
|
|
41
|
+
};
|
|
42
|
+
function errorOf(error) {
|
|
43
|
+
const name = error instanceof DOMException ? error.name : "";
|
|
44
|
+
return {
|
|
45
|
+
kind: name === "NotFoundError"
|
|
46
|
+
? 1
|
|
47
|
+
: name === "NotAllowedError" || name === "SecurityError"
|
|
48
|
+
? 2
|
|
49
|
+
: 0,
|
|
50
|
+
message: error instanceof Error ? error.message : String(error),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function pathOf(value) {
|
|
54
|
+
if (value?.kind !== "string")
|
|
55
|
+
throw new Error("directory operation requires a string path");
|
|
56
|
+
const path = value.value;
|
|
57
|
+
if (!path ||
|
|
58
|
+
path.startsWith("/") ||
|
|
59
|
+
path.includes("\\") ||
|
|
60
|
+
path.includes("\0") ||
|
|
61
|
+
/^[A-Za-z]:/.test(path))
|
|
62
|
+
throw new DOMException("path must stay beneath the supplied directory", "NotAllowedError");
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
function ownerOf(value, access) {
|
|
66
|
+
if (value?.kind !== "host_resource" ||
|
|
67
|
+
value.owner.closed ||
|
|
68
|
+
!value.owner.handle ||
|
|
69
|
+
value.owner.access !== access)
|
|
70
|
+
throw new Error("closed or incompatible filesystem owner");
|
|
71
|
+
return value.owner;
|
|
72
|
+
}
|
|
73
|
+
/** Resolve actual entries in order. A child starts a new confinement boundary;
|
|
74
|
+
* parent components never recover the parent's authority. FSA exposes no symlink
|
|
75
|
+
* traversal primitive: each lookup stays beneath its concrete directory handle. */
|
|
76
|
+
async function directory(root, parts) {
|
|
77
|
+
const stack = [root];
|
|
78
|
+
for (const part of parts) {
|
|
79
|
+
if (!part || part === ".")
|
|
80
|
+
continue;
|
|
81
|
+
if (part === "..") {
|
|
82
|
+
if (stack.length === 1)
|
|
83
|
+
throw new DOMException("path escapes its directory owner", "NotAllowedError");
|
|
84
|
+
stack.pop();
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
stack.push(await stack[stack.length - 1].getDirectoryHandle(part));
|
|
88
|
+
}
|
|
89
|
+
return stack[stack.length - 1];
|
|
90
|
+
}
|
|
91
|
+
export class BrowserDirectories {
|
|
92
|
+
roots;
|
|
93
|
+
// The adapter retains its physical owner slots until selected release. This
|
|
94
|
+
// also covers acquisition completed after a terminal VM failure, when its
|
|
95
|
+
// source continuation must never run.
|
|
96
|
+
owners = new Set();
|
|
97
|
+
retire(release) {
|
|
98
|
+
for (const value of this.owners)
|
|
99
|
+
release(value);
|
|
100
|
+
}
|
|
101
|
+
/** OPFS has no symlink entries. Picker-backed filesystem link confinement is
|
|
102
|
+
* not standardized, so this adapter refuses those handles before execution. */
|
|
103
|
+
static async admission(roots) {
|
|
104
|
+
const supplied = [roots?.read, roots?.write].filter((root) => root !== undefined);
|
|
105
|
+
if (supplied.length === 0)
|
|
106
|
+
return null;
|
|
107
|
+
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory)
|
|
108
|
+
return "This host does not provide the browser origin-private filesystem.";
|
|
109
|
+
try {
|
|
110
|
+
const canonical = await navigator.storage.getDirectory();
|
|
111
|
+
for (const root of supplied)
|
|
112
|
+
if (root.kind !== "directory" ||
|
|
113
|
+
(await canonical.resolve(root)) === null)
|
|
114
|
+
return "Only supplied origin-private directory handles are supported; external picker directory confinement is unavailable.";
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return `Cannot validate supplied directory authority: ${errorOf(error).message}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
constructor(roots = {}) {
|
|
122
|
+
this.roots = roots;
|
|
123
|
+
}
|
|
124
|
+
call(operation, args, resource) {
|
|
125
|
+
if (operation.action === "valid") {
|
|
126
|
+
const value = args[0];
|
|
127
|
+
return {
|
|
128
|
+
kind: "bool",
|
|
129
|
+
value: value?.kind === "host_resource" &&
|
|
130
|
+
!value.owner.closed &&
|
|
131
|
+
value.owner.handle !== null &&
|
|
132
|
+
value.owner.access === operation.access,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (operation.action === "close") {
|
|
136
|
+
const value = args[0];
|
|
137
|
+
if (value?.kind !== "host_resource")
|
|
138
|
+
throw new Error("close requires a filesystem owner");
|
|
139
|
+
value.owner.closed = true;
|
|
140
|
+
value.owner.handle = null;
|
|
141
|
+
this.owners.delete(value);
|
|
142
|
+
return UNIT;
|
|
143
|
+
}
|
|
144
|
+
if (resource == null)
|
|
145
|
+
throw new Error("filesystem acquisition has no compiler-selected owner slot");
|
|
146
|
+
return this.acquire(operation, args, resource);
|
|
147
|
+
}
|
|
148
|
+
async acquire(operation, args, resource) {
|
|
149
|
+
let owner = operation.action === "file"
|
|
150
|
+
? {
|
|
151
|
+
access: operation.access,
|
|
152
|
+
handle: null,
|
|
153
|
+
cursor: 0,
|
|
154
|
+
closed: false,
|
|
155
|
+
tail: Promise.resolve(),
|
|
156
|
+
}
|
|
157
|
+
: { access: operation.access, handle: null, closed: false };
|
|
158
|
+
try {
|
|
159
|
+
const path = pathOf(args[operation.action === "root" ? 0 : 1]);
|
|
160
|
+
const parent = operation.action === "root"
|
|
161
|
+
? this.roots[operation.access]
|
|
162
|
+
: ownerOf(args[0], operation.access).handle;
|
|
163
|
+
if (!parent || parent.kind !== "directory")
|
|
164
|
+
throw new DOMException("no supplied directory authority", "NotAllowedError");
|
|
165
|
+
const parts = path.split("/");
|
|
166
|
+
if (operation.action === "file") {
|
|
167
|
+
const name = parts.pop();
|
|
168
|
+
if (!name || name === "." || name === "..")
|
|
169
|
+
throw new Error("file path must name a regular file");
|
|
170
|
+
const target = await directory(parent, parts);
|
|
171
|
+
const file = await target.getFileHandle(name, {
|
|
172
|
+
create: operation.access === "write",
|
|
173
|
+
});
|
|
174
|
+
if (operation.access === "write") {
|
|
175
|
+
// Native open_write truncates before publishing the owner. FSA only
|
|
176
|
+
// publishes writes on close, so close this truncation before resuming.
|
|
177
|
+
const writer = await file.createWritable();
|
|
178
|
+
try {
|
|
179
|
+
await writer.close();
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
await writer.abort().catch(() => { });
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
owner = {
|
|
187
|
+
access: operation.access,
|
|
188
|
+
handle: file,
|
|
189
|
+
cursor: 0,
|
|
190
|
+
closed: false,
|
|
191
|
+
tail: Promise.resolve(),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
else
|
|
195
|
+
owner.handle = await directory(parent, parts);
|
|
196
|
+
return this.owned(resource, owner, null);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return this.owned(resource, owner, errorOf(error));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
owned(resource, owner, error) {
|
|
203
|
+
const value = {
|
|
204
|
+
kind: "host_resource",
|
|
205
|
+
resource,
|
|
206
|
+
owner,
|
|
207
|
+
};
|
|
208
|
+
this.owners.add(value);
|
|
209
|
+
return { value, error };
|
|
210
|
+
}
|
|
211
|
+
io(operation, args, cancelled) {
|
|
212
|
+
const fallback = operation === "FileRead"
|
|
213
|
+
? { kind: "vector", elementType: "u8", items: [] }
|
|
214
|
+
: { kind: "i64", value: -1n };
|
|
215
|
+
let owner;
|
|
216
|
+
try {
|
|
217
|
+
const selected = ownerOf(args[0], operation === "FileRead" ? "read" : "write");
|
|
218
|
+
if (!("cursor" in selected))
|
|
219
|
+
throw new Error("file operation requires a file owner");
|
|
220
|
+
owner = selected;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
return Promise.resolve({ value: fallback, error: errorOf(error) });
|
|
224
|
+
}
|
|
225
|
+
const result = owner.tail.then(async () => {
|
|
226
|
+
try {
|
|
227
|
+
if (cancelled())
|
|
228
|
+
return { value: fallback, error: null };
|
|
229
|
+
const handle = owner.handle;
|
|
230
|
+
if (!handle || owner.closed)
|
|
231
|
+
throw new Error("closed file owner");
|
|
232
|
+
if (operation === "FileRead") {
|
|
233
|
+
const file = await handle.getFile();
|
|
234
|
+
if (cancelled())
|
|
235
|
+
return { value: fallback, error: null };
|
|
236
|
+
const bytes = new Uint8Array(await file.slice(owner.cursor).arrayBuffer());
|
|
237
|
+
owner.cursor += bytes.length;
|
|
238
|
+
return {
|
|
239
|
+
value: {
|
|
240
|
+
kind: "vector",
|
|
241
|
+
elementType: "u8",
|
|
242
|
+
items: Array.from(bytes, (value) => ({
|
|
243
|
+
kind: "i64",
|
|
244
|
+
value: BigInt(value),
|
|
245
|
+
})),
|
|
246
|
+
},
|
|
247
|
+
error: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const data = args[1];
|
|
251
|
+
if (data?.kind !== "vector")
|
|
252
|
+
throw new Error("file write requires bytes");
|
|
253
|
+
const bytes = Uint8Array.from(data.items, (value) => {
|
|
254
|
+
if (value.kind !== "i64")
|
|
255
|
+
throw new Error("file write requires byte elements");
|
|
256
|
+
return Number(value.value);
|
|
257
|
+
});
|
|
258
|
+
const writer = await handle.createWritable({ keepExistingData: true });
|
|
259
|
+
try {
|
|
260
|
+
if (cancelled()) {
|
|
261
|
+
await writer.abort();
|
|
262
|
+
return { value: fallback, error: null };
|
|
263
|
+
}
|
|
264
|
+
await writer.seek(owner.cursor);
|
|
265
|
+
await writer.write(bytes);
|
|
266
|
+
if (cancelled()) {
|
|
267
|
+
await writer.abort();
|
|
268
|
+
return { value: fallback, error: null };
|
|
269
|
+
}
|
|
270
|
+
await writer.close();
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
await writer.abort().catch(() => { });
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
owner.cursor += bytes.length;
|
|
277
|
+
return { value: { kind: "i64", value: 0n }, error: null };
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return { value: fallback, error: errorOf(error) };
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
// Independent files progress independently; each cursor serializes its own
|
|
284
|
+
// borrowed operations as the native file owner's lock does.
|
|
285
|
+
owner.tail = result.then(() => { });
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
}
|