Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion scripts/lint/test-typecheck-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"src/platform/adapters/fs/veryfront/adapter-helpers.test.ts",
"src/platform/adapters/fs/veryfront/directory-operations.test.ts",
"src/platform/adapters/redis/node.test.ts",
"src/platform/adapters/runtime/shared/watcher-queue.test.ts",
"src/platform/adapters/token/factory.test.ts",
"src/prompt/factory.test.ts",
"src/react/components/chat/chat.test.tsx",
Expand Down
27 changes: 17 additions & 10 deletions src/fs/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import * as fsModule from "./index.ts";
import * as publicFsModule from "veryfront/fs";
import type { FileSystem as PublicFileSystem } from "veryfront/fs";
import * as compatFsModule from "#veryfront/platform/compat/fs.ts";
import * as snapshotErrorModule from "#veryfront/platform/adapters/file-snapshot-error.ts";
import * as pathModule from "#veryfront/platform/compat/path/index.ts";
import * as processModule from "#veryfront/platform/compat/process.ts";

Expand Down Expand Up @@ -51,14 +51,6 @@ describe("fs/index.ts exports", () => {
assertEquals(fsModule.join, pathModule.join);
assertEquals(fsModule.resolve, pathModule.resolve);
assertEquals(fsModule.cwd, processModule.cwd);
assertEquals(
fsModule.FileSnapshotChangedError,
snapshotErrorModule.FileSnapshotChangedError,
);
assertEquals(
fsModule.isFileSnapshotChangedError,
snapshotErrorModule.isFileSnapshotChangedError,
);
});

it("keeps the public barrel intentionally slim", () => {
Expand All @@ -75,6 +67,21 @@ describe("fs/index.ts exports", () => {
assertEquals(publicFsModule.readTextFile, fsModule.readTextFile);
assertEquals(publicFsModule.resolve, fsModule.resolve);
assertEquals(publicFsModule.cwd, fsModule.cwd);
assertEquals(publicFsModule.FileSnapshotChangedError, fsModule.FileSnapshotChangedError);

const fileSystem: PublicFileSystem = publicFsModule.createFileSystem();
assertEquals(typeof fileSystem.makeTempDir, "function");
assertEquals(typeof fileSystem.chmod, "function");
assertEquals(typeof fileSystem.remove, "function");

const changed = new publicFsModule.FileSnapshotChangedError("changed");
assertEquals(publicFsModule.isFileSnapshotChangedError(changed), true);
assertEquals(fsModule.isFileSnapshotChangedError(changed), true);
assertEquals(
publicFsModule.isFileSnapshotChangedError({
name: "FileSnapshotChangedError",
message: "changed",
}),
false,
);
});
});
4 changes: 2 additions & 2 deletions src/fs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
* ```ts
* import { exists, mkdir, readTextFile, writeTextFile } from "veryfront/fs";
*
* const content = await readTextFile("./data/config.json");
* await writeTextFile("./output/result.json", JSON.stringify(data));
* const data = JSON.parse(await readTextFile("./data/config.json"));
* await mkdir("./output", { recursive: true });
* await writeTextFile("./output/result.json", JSON.stringify(data));
* ```
*
* @example Path utilities
Expand Down
105 changes: 105 additions & 0 deletions src/platform/adapters/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ServerAdapter,
ShellAdapter,
WatchOptions,
WebSocketUpgradeResponse,
} from "./base.ts";

describe("base.ts type exports", () => {
Expand All @@ -30,9 +31,113 @@ describe("base.ts type exports", () => {
assertEquals(response.body, null);
assertEquals(response.headers.get("upgrade"), "websocket");
assertEquals(response instanceof Response, false);
assertEquals(Object.isFrozen(response), true);
assertEquals(isWebSocketUpgradeResponse(response), true);
assertEquals(isWebSocketUpgradeResponse(new Response()), false);
});

it("accepts the public structural upgrade-response contract", () => {
const response: WebSocketUpgradeResponse = {
kind: "websocket-upgrade",
status: 101,
statusText: "Switching Protocols",
headers: new Headers({ upgrade: "websocket" }),
body: null,
};

assertEquals(isWebSocketUpgradeResponse(response), true);
});

it("recognizes upgrade signals created by another module instance", async () => {
const duplicate = await import("./base.ts?websocket-upgrade-duplicate");
const response = createWebSocketUpgradeResponse();

assertEquals(duplicate.isWebSocketUpgradeResponse(response), true);
});

it("does not invoke accessors while identifying an upgrade response", () => {
let accessorReads = 0;
const value = Object.defineProperties({}, {
kind: {
enumerable: true,
get() {
accessorReads++;
return "websocket-upgrade";
},
},
status: { enumerable: true, value: 101 },
});

assertEquals(isWebSocketUpgradeResponse(value), false);
assertEquals(accessorReads, 0);
});

it("short-circuits a mismatched kind before inspecting other fields", () => {
let otherFieldReads = 0;
const value = new Proxy({}, {
getOwnPropertyDescriptor(_target, key) {
if (key === "kind") {
return {
configurable: true,
value: "http-response",
};
}
otherFieldReads++;
throw new Error("non-upgrade fields must not be inspected");
},
});

assertEquals(isWebSocketUpgradeResponse(value), false);
assertEquals(otherFieldReads, 0);
});

it("bounds prototype traversal when a Proxy returns fresh prototypes", () => {
let descriptorReads = 0;
let prototypeReads = 0;
const createFreshPrototype = (): object =>
new Proxy({}, {
getOwnPropertyDescriptor() {
descriptorReads++;
return undefined;
},
getPrototypeOf() {
prototypeReads++;
return createFreshPrototype();
},
});

assertEquals(isWebSocketUpgradeResponse(createFreshPrototype()), false);
assertEquals(descriptorReads, 16);
assertEquals(prototypeReads, 16);
});

it("rejects incomplete structural upgrade signals", () => {
const incomplete = {
kind: "websocket-upgrade",
status: 101,
};

assertEquals(isWebSocketUpgradeResponse(incomplete), false);
});

it("rejects a forged Headers-shaped object without invoking it", () => {
let calls = 0;
const forged = {
kind: "websocket-upgrade",
status: 101,
statusText: "Switching Protocols",
headers: {
get() {
calls++;
return "websocket";
},
},
body: null,
};

assertEquals(isWebSocketUpgradeResponse(forged), false);
assertEquals(calls, 0);
});
});

describe("RuntimeId", () => {
Expand Down
63 changes: 54 additions & 9 deletions src/platform/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,17 @@ export function isWebSocketUpgradeResponse(value: unknown): value is WebSocketUp
return false;
}
try {
// Verify the native Headers internal slot without consulting hostile
// properties or Symbol.hasInstance hooks.
// A structural object with a `get()` method is not a Headers instance and
// can run arbitrary code when the transport clones it. Invoke a captured
// Web IDL primordial to verify the native internal slot without consulting
// attacker-controlled properties or Symbol.hasInstance hooks.
Reflect.apply(headersGet, headers.value, ["upgrade"]);
} catch {
return false;
}

const body = readDataProperty(value, "body");

return body.readable && body.value === null;
}

Expand Down Expand Up @@ -222,18 +225,51 @@ export interface Server {
}

export interface FileSystemAdapter {
/** Explicit marker for virtual filesystems that never resolve symbolic links. */
/**
* Explicitly declares that paths in this adapter cannot traverse symbolic
* links. The backing store may reject links or expose them only as inert
* entries, but it must never resolve a path through one. Native/local
* adapters must omit this marker and provide lstat and realPath instead.
*/
readonly symlinkSemantics?: "none";
readFile(path: string): Promise<string>;
/** Read raw bytes when binary-safe access is required */
readFileBytes?(path: string): Promise<Uint8Array>;
/** Fixed upstream whole-object ceiling, valid only with `readFileBytes`. */
/**
* Fixed whole-object ceiling enforced by the backing store or transport
* before a complete response can be materialized.
*
* This capability is distinct from `readFileBytesBounded`: the caller does
* not choose the read size, and the implementation may materialize up to
* this advertised ceiling even for a smaller file. It may be advertised
* only alongside `readFileBytes` and only when the upstream boundary itself
* rejects larger objects before returning them.
*/
readonly maxWholeFileReadBytes?: number;
/** Read at most a caller-selected byte prefix without materializing the whole source. */
/**
* Read a prefix without materializing more than `byteLimit` bytes.
*
* Implementations must enforce the limit while reading from their backing
* store and continue until EOF or `byteLimit`; reading the complete object
* and slicing afterward does not satisfy this capability. Callers can
* request their accepted maximum plus one byte to distinguish an exact-size
* file from an oversized file. Non-native adapters used for bounded Skill
* discovery or strict Skill runtime reads must implement this capability.
*/
readFileBytesBounded?(path: string, byteLimit: number): Promise<Uint8Array>;
/** Read a complete file only when it fits within the caller-selected limit. */
/**
* Read the complete file only when it is no larger than `byteLimit`.
*
* Implementations must enforce the limit while reading and reject when the
* source has even one additional byte. They must not implement this by
* materializing the whole object or by retaining a `byteLimit + 1` prefix.
* Oversized sources reject with `RangeError`; other I/O failures propagate.
*/
readFileBytesWithinLimit?(path: string, byteLimit: number): Promise<Uint8Array>;
/** Read one verified native snapshot beneath `containmentRoot`. */
/**
* Read one stable file snapshot beneath `containmentRoot` without following
* links and only when its complete contents fit within `byteLimit`.
*/
readFileSnapshotWithinLimit?(
path: string,
containmentRoot: string,
Expand All @@ -242,7 +278,7 @@ export interface FileSystemAdapter {
writeFile(path: string, content: string): Promise<void>;
/** Write raw bytes when binary-safe output is required. */
writeFileBytes?(path: string, content: Uint8Array): Promise<void>;
/** Create a new file atomically, refusing to replace an existing path. */
/** Create a new byte file without replacing an existing path. */
createFileBytesExclusive?(path: string, content: Uint8Array): Promise<void>;
/** Atomically replace a path when the runtime supports same-filesystem rename. */
rename?(from: string, to: string): Promise<void>;
Expand Down Expand Up @@ -285,10 +321,12 @@ export interface FileSystemAdapter {
getSourceSnapshotVersion?(): number | undefined | Promise<number | undefined>;
}

/** A filesystem adapter that advertises genuine bounded byte reads. */
export type BoundedFileSystemAdapter =
& FileSystemAdapter
& Required<Pick<FileSystemAdapter, "readFileBytesBounded">>;

/** A filesystem adapter that can return only complete, size-admitted files. */
export type ExactBoundedFileSystemAdapter =
& FileSystemAdapter
& Required<Pick<FileSystemAdapter, "readFileBytesWithinLimit">>;
Expand Down Expand Up @@ -332,11 +370,18 @@ export interface FileChangeEvent {

export interface FileWatcher extends AsyncIterable<FileChangeEvent> {
close(): void;
/**
* Resolves once the underlying watcher has been installed and can observe
* subsequent filesystem changes. Rejects when any requested watch root
* cannot be acquired; callers must not advertise watching before it resolves.
*/
ready?: Promise<void>;
/**
* Resolves once the watcher's internal loop has fully stopped, including
* any in-flight filesystem operations. close() only signals shutdown;
* await this to guarantee no pending async ops remain (e.g. before test
* sanitizer checks or process exit).
* sanitizer checks or process exit). Rejects when the native watcher fails
* or teardown cannot complete cleanly.
*/
done?: Promise<void>;
}
Expand Down
32 changes: 22 additions & 10 deletions src/platform/adapters/bounded-file-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,26 @@ function viewBytes(value: Uint8Array, start: number, byteLength: number): Uint8A
// bounded by the caller's limit.
const BOUNDED_FILE_READ_CHUNK_BYTES = 64 * 1024;

async function readAndCloseHandle<T>(
openHandle: () => Promise<BoundedFileReadHandle>,
read: (handle: BoundedFileReadHandle) => Promise<T>,
): Promise<T> {
/**
* Run one operation against one opened file handle, then close it exactly once.
*
* When the operation and cleanup both fail, the aggregate retains the primary
* operation failure first and the cleanup failure second.
*/
export async function withFileHandle<
THandle extends { close(): void | Promise<void> },
TResult,
>(
openHandle: () => Promise<THandle>,
operation: (handle: THandle) => Promise<TResult>,
aggregateFailureMessage: string,
): Promise<TResult> {
const handle = await openHandle();
let result: T | undefined;
let result: TResult | undefined;
let primaryFailure: unknown;
let primaryFailed = false;
try {
result = await read(handle);
result = await operation(handle);
} catch (error) {
primaryFailed = true;
primaryFailure = error;
Expand All @@ -103,14 +113,14 @@ async function readAndCloseHandle<T>(
if (primaryFailed) {
throw new AggregateError(
[primaryFailure, cleanupFailure],
"Filesystem read and handle cleanup both failed",
aggregateFailureMessage,
);
}
throw cleanupFailure;
}

if (primaryFailed) throw primaryFailure;
return result as T;
return result as TResult;
}

export function requireBoundedFileReadLimit(byteLimit: unknown): number {
Expand All @@ -132,9 +142,10 @@ export async function readBoundedFilePrefix(
byteLimit: number,
): Promise<Uint8Array> {
const boundedLimit = requireBoundedFileReadLimit(byteLimit);
return await readAndCloseHandle(
return await withFileHandle(
openHandle,
(handle) => readBoundedFileHandlePrefix(handle, boundedLimit),
"Filesystem read and handle cleanup both failed",
);
}

Expand All @@ -150,9 +161,10 @@ export async function readFileWithinLimit(
byteLimit: number,
): Promise<Uint8Array> {
const boundedLimit = requireBoundedFileReadLimit(byteLimit);
return await readAndCloseHandle(
return await withFileHandle(
openHandle,
(handle) => readFileHandleWithinLimit(handle, boundedLimit),
"Filesystem read and handle cleanup both failed",
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/platform/adapters/bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("bun.ts exports", () => {
assertEquals(bunAdapter.capabilities.typescript, true);
assertEquals(bunAdapter.capabilities.jsx, true);
assertEquals(bunAdapter.capabilities.websocket, true);
assertEquals(bunAdapter.capabilities.http2, false);
assertEquals(bunAdapter.capabilities.http2, true);

assertEquals(typeof bunAdapter.serve, "function");
});
Expand Down
Loading
Loading