Initial Commit
This commit is contained in:
10
packages/devtools/tooling/sandbox/src/schemas/index.ts
Normal file
10
packages/devtools/tooling/sandbox/src/schemas/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type { SandboxConfigPartial } from "./schemas.js";
|
||||
export {
|
||||
parseConfig,
|
||||
parseModule,
|
||||
parseOptions,
|
||||
SandboxConfigPartialSchema,
|
||||
SandboxEngineKindSchema,
|
||||
SandboxModuleSchema,
|
||||
SandboxOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
65
packages/devtools/tooling/sandbox/src/schemas/schemas.md
Normal file
65
packages/devtools/tooling/sandbox/src/schemas/schemas.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas guard every public boundary of `@suaveplan/sandbox`. The package never trusts its caller; instead, every argument that crosses the API boundary is parsed against a strict schema and rejected with a typed `SandboxError` (`INVALID_CONFIG` / `INVALID_OPTIONS` / `INVALID_MODULE`) when the shape does not match. This means downstream consumers get the same validation guarantees whether they pass typed TypeScript objects or arbitrary JSON deserialised from a network boundary.
|
||||
|
||||
The `parseConfig` helper additionally merges parsed partials with `DEFAULT_CONFIG` so the rest of the package consumes a fully resolved `SandboxConfig` rather than a partial. This keeps the merge logic in one place rather than re-implementing it across the facade and the engines.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-mode schemas (`.strict()`) so unknown keys are rejected.
|
||||
- `parseConfig`, `parseOptions`, `parseModule` helpers that translate Zod
|
||||
errors into `SandboxError` with the right code.
|
||||
- `SandboxEngineKindSchema` enum used as the discriminant for engine selection.
|
||||
- `safeParse` under the hood so the error path stays on the catch-side and
|
||||
the success path stays on the happy path.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseConfig } from "@suaveplan/sandbox/schemas";
|
||||
|
||||
const cfg = parseConfig({ timeout: 1000, engine: "worker" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
When validating untrusted input (HTTP, IPC), surface the raw Zod issues to
|
||||
the caller via `SandboxError.details.issues`:
|
||||
|
||||
```ts
|
||||
import { SandboxError } from "@suaveplan/sandbox/error-codes";
|
||||
import { parseConfig } from "@suaveplan/sandbox/schemas";
|
||||
|
||||
try {
|
||||
parseConfig(JSON.parse(httpBody));
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxError) {
|
||||
return Response.json({ issues: err.details.issues }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SandboxConfigPartialSchema` — `z.ZodObject` for `Partial<SandboxConfig>`.
|
||||
- `SandboxOptionsSchema` — `z.ZodObject` for `SandboxOptions`.
|
||||
- `SandboxModuleSchema` — `z.ZodObject` for `SandboxModule`.
|
||||
- `SandboxEngineKindSchema` — `z.ZodEnum<['vm','worker','spawn','isolate']>`.
|
||||
- `parseConfig(raw): SandboxConfig` — merges with `DEFAULT_CONFIG`.
|
||||
- `parseOptions(raw): SandboxOptions`.
|
||||
- `parseModule(raw): SandboxModule`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`onAuditLog` is validated via `z.custom` rather than `z.function` because Zod's
|
||||
function schema would otherwise wrap the callback in a runtime validator and
|
||||
break referential equality, breaking the "consumer-passes-its-own-callback"
|
||||
contract. The custom validator only checks `typeof === "function"` (or
|
||||
undefined), which is the actual invariant the consumer cares about.
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_CONFIG } from "../types/sandbox.types.js";
|
||||
import { parseConfig, parseModule, parseOptions } from "./schemas.js";
|
||||
|
||||
describe("parseConfig", () => {
|
||||
it("returns DEFAULT_CONFIG when given undefined", { timeout: 5000 }, () => {
|
||||
const cfg = parseConfig(undefined);
|
||||
expect(cfg.timeout).toBe(DEFAULT_CONFIG.timeout);
|
||||
expect(cfg.engine).toBe("vm");
|
||||
expect(cfg.allowedModules).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges partial config with defaults", { timeout: 5000 }, () => {
|
||||
const cfg = parseConfig({ timeout: 1000, engine: "worker" });
|
||||
expect(cfg.timeout).toBe(1000);
|
||||
expect(cfg.engine).toBe("worker");
|
||||
expect(cfg.memoryLimit).toBe(DEFAULT_CONFIG.memoryLimit);
|
||||
});
|
||||
|
||||
it("includes onAuditLog when supplied", { timeout: 5000 }, () => {
|
||||
const cb = () => {};
|
||||
const cfg = parseConfig({ onAuditLog: cb });
|
||||
expect(cfg.onAuditLog).toBe(cb);
|
||||
});
|
||||
|
||||
it("rejects negative timeout", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseConfig({ timeout: -1 } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxError);
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown engine kinds", { timeout: 5000 }, () => {
|
||||
expect(() => parseConfig({ engine: "rust-wasm" } as never)).toThrow(
|
||||
SandboxError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOptions", () => {
|
||||
it("accepts undefined", { timeout: 5000 }, () => {
|
||||
expect(parseOptions(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a valid options bag", { timeout: 5000 }, () => {
|
||||
expect(parseOptions({ timeout: 100 })).toEqual({ timeout: 100 });
|
||||
});
|
||||
|
||||
it("rejects negative timeout", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseOptions({ timeout: -5 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes memoryLimit + maxOutputSize through", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseOptions({ memoryLimit: 1000, maxOutputSize: 50 })).toEqual({
|
||||
memoryLimit: 1000,
|
||||
maxOutputSize: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseModule", () => {
|
||||
it("accepts a valid module", { timeout: 5000 }, () => {
|
||||
const mod = parseModule({ name: "config", exports: { x: 1 } });
|
||||
expect(mod.name).toBe("config");
|
||||
expect(mod.exports.x).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an empty name", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseModule({ name: "", exports: {} });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_MODULE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing exports field", { timeout: 5000 }, () => {
|
||||
expect(() => parseModule({ name: "x" })).toThrow(SandboxError);
|
||||
});
|
||||
});
|
||||
129
packages/devtools/tooling/sandbox/src/schemas/schemas.ts
Normal file
129
packages/devtools/tooling/sandbox/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import z from "zod";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
type SandboxConfig,
|
||||
type SandboxModule,
|
||||
type SandboxOptions,
|
||||
} from "../types/sandbox.types.js";
|
||||
|
||||
/** Engine kinds accepted by `createSandbox`. */
|
||||
export const SandboxEngineKindSchema = z.enum([
|
||||
"vm",
|
||||
"worker",
|
||||
"spawn",
|
||||
"isolate",
|
||||
]);
|
||||
|
||||
/** Partial config schema (used at `createSandbox` boundary). */
|
||||
export const SandboxConfigPartialSchema = z
|
||||
.object({
|
||||
timeout: z.number().int().positive().optional(),
|
||||
memoryLimit: z.number().int().positive().optional(),
|
||||
allowedModules: z.array(z.string().min(1)).optional(),
|
||||
maxOutputSize: z.number().int().positive().optional(),
|
||||
maxExecutionsPerWindow: z.number().int().positive().optional(),
|
||||
rateLimitWindow: z.number().int().positive().optional(),
|
||||
maxCodeLength: z.number().int().positive().optional(),
|
||||
engine: SandboxEngineKindSchema.optional(),
|
||||
onAuditLog: z
|
||||
.custom<SandboxConfig["onAuditLog"]>(
|
||||
(v) => v === undefined || typeof v === "function",
|
||||
"onAuditLog must be a function"
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SandboxConfigPartial = z.infer<typeof SandboxConfigPartialSchema>;
|
||||
|
||||
/** Per-execution options schema. */
|
||||
export const SandboxOptionsSchema = z
|
||||
.object({
|
||||
timeout: z.number().int().positive().optional(),
|
||||
memoryLimit: z.number().int().positive().optional(),
|
||||
maxOutputSize: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Sandbox-module schema. */
|
||||
export const SandboxModuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
exports: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Parse a partial config and merge with `DEFAULT_CONFIG`. Throws
|
||||
* `SandboxError(INVALID_CONFIG)` on parse failure.
|
||||
*/
|
||||
export function parseConfig(
|
||||
raw: SandboxConfigPartial | undefined
|
||||
): SandboxConfig {
|
||||
const parsed = SandboxConfigPartialSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.INVALID_CONFIG,
|
||||
"Invalid sandbox config",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const merged: SandboxConfig = {
|
||||
timeout: parsed.data.timeout ?? DEFAULT_CONFIG.timeout,
|
||||
memoryLimit: parsed.data.memoryLimit ?? DEFAULT_CONFIG.memoryLimit,
|
||||
allowedModules:
|
||||
parsed.data.allowedModules ?? DEFAULT_CONFIG.allowedModules,
|
||||
maxOutputSize:
|
||||
parsed.data.maxOutputSize ?? DEFAULT_CONFIG.maxOutputSize,
|
||||
maxExecutionsPerWindow:
|
||||
parsed.data.maxExecutionsPerWindow ??
|
||||
DEFAULT_CONFIG.maxExecutionsPerWindow,
|
||||
rateLimitWindow:
|
||||
parsed.data.rateLimitWindow ?? DEFAULT_CONFIG.rateLimitWindow,
|
||||
maxCodeLength:
|
||||
parsed.data.maxCodeLength ?? DEFAULT_CONFIG.maxCodeLength,
|
||||
engine: parsed.data.engine ?? DEFAULT_CONFIG.engine,
|
||||
...(parsed.data.onAuditLog === undefined
|
||||
? {}
|
||||
: { onAuditLog: parsed.data.onAuditLog }),
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Parse per-execution options. Throws `SandboxError(INVALID_OPTIONS)`. */
|
||||
export function parseOptions(raw: unknown): SandboxOptions {
|
||||
const parsed = SandboxOptionsSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.INVALID_OPTIONS,
|
||||
"Invalid sandbox options",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const out: SandboxOptions = {
|
||||
...(parsed.data.timeout === undefined
|
||||
? {}
|
||||
: { timeout: parsed.data.timeout }),
|
||||
...(parsed.data.memoryLimit === undefined
|
||||
? {}
|
||||
: { memoryLimit: parsed.data.memoryLimit }),
|
||||
...(parsed.data.maxOutputSize === undefined
|
||||
? {}
|
||||
: { maxOutputSize: parsed.data.maxOutputSize }),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse a SandboxModule. Throws `SandboxError(INVALID_MODULE)`. */
|
||||
export function parseModule(raw: unknown): SandboxModule {
|
||||
const parsed = SandboxModuleSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.INVALID_MODULE,
|
||||
"Invalid sandbox module",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
Reference in New Issue
Block a user