Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
131
extensions/file-transfer/src/node-host/dir-fetch.test.ts
Normal file
131
extensions/file-transfer/src/node-host/dir-fetch.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// File Transfer tests cover dir fetch plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { handleDirFetch } from "./dir-fetch.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
|
||||
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-fetch-test-")));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// dir-fetch shells out to /usr/bin/tar. Skip the body of these tests on
|
||||
// platforms without it (Windows CI). They still register, just no-op.
|
||||
const HAS_TAR = process.platform !== "win32";
|
||||
|
||||
async function expectDirFetchError(input: Parameters<typeof handleDirFetch>[0], code: string) {
|
||||
const result = await handleDirFetch(input);
|
||||
if (result.ok) {
|
||||
throw new Error("expected directory fetch error");
|
||||
}
|
||||
expect(result.code).toBe(code);
|
||||
}
|
||||
|
||||
describe("handleDirFetch — input validation", () => {
|
||||
it("rejects empty / non-string path", async () => {
|
||||
await expectDirFetchError({ path: "" }, "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects relative paths", async () => {
|
||||
await expectDirFetchError({ path: "relative" }, "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects paths with NUL bytes", async () => {
|
||||
await expectDirFetchError({ path: "/tmp/foo\0bar" }, "INVALID_PATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirFetch — fs errors", () => {
|
||||
it.runIf(HAS_TAR)("returns NOT_FOUND for a missing directory", async () => {
|
||||
await expectDirFetchError({ path: path.join(tmpRoot, "missing") }, "NOT_FOUND");
|
||||
});
|
||||
|
||||
it.runIf(HAS_TAR)("returns IS_FILE when path resolves to a file", async () => {
|
||||
const f = path.join(tmpRoot, "f.txt");
|
||||
await fs.writeFile(f, "x");
|
||||
await expectDirFetchError({ path: f }, "IS_FILE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirFetch — happy path", () => {
|
||||
it("preflights directory entries without creating a tarball", async () => {
|
||||
await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
|
||||
await fs.mkdir(path.join(tmpRoot, ".ssh"));
|
||||
await fs.writeFile(path.join(tmpRoot, ".ssh", "id_rsa"), "secret\n");
|
||||
await fs.mkdir(path.join(tmpRoot, "sub"));
|
||||
await fs.writeFile(path.join(tmpRoot, "sub", "b.txt"), "beta\n");
|
||||
|
||||
const r = await handleDirFetch({ path: tmpRoot, preflightOnly: true });
|
||||
if (!r.ok) {
|
||||
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
||||
}
|
||||
|
||||
expect(r.path).toBe(tmpRoot);
|
||||
expect(r.tarBase64).toBe("");
|
||||
expect(r.tarBytes).toBe(0);
|
||||
expect(r.sha256).toBe("");
|
||||
expect(r.preflightOnly).toBe(true);
|
||||
expect(r.entries).toEqual([".ssh", ".ssh/id_rsa", "a.txt", "sub", "sub/b.txt"]);
|
||||
expect(r.fileCount).toBe(r.entries?.length);
|
||||
});
|
||||
|
||||
it.runIf(HAS_TAR)("returns a gzipped tar with byte count and sha256", async () => {
|
||||
await fs.writeFile(path.join(tmpRoot, "a.txt"), "alpha\n");
|
||||
await fs.writeFile(path.join(tmpRoot, "b.txt"), "beta\n");
|
||||
await fs.mkdir(path.join(tmpRoot, "sub"));
|
||||
await fs.writeFile(path.join(tmpRoot, "sub", "c.txt"), "gamma\n");
|
||||
|
||||
const r = await handleDirFetch({ path: tmpRoot });
|
||||
if (!r.ok) {
|
||||
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
||||
}
|
||||
|
||||
expect(r.tarBytes).toBeGreaterThan(0);
|
||||
expect(r.tarBase64.length).toBeGreaterThan(0);
|
||||
|
||||
const buf = Buffer.from(r.tarBase64, "base64");
|
||||
expect(buf.byteLength).toBe(r.tarBytes);
|
||||
|
||||
const expectedSha = crypto.createHash("sha256").update(buf).digest("hex");
|
||||
expect(r.sha256).toBe(expectedSha);
|
||||
|
||||
// gzip magic bytes
|
||||
expect(buf[0]).toBe(0x1f);
|
||||
expect(buf[1]).toBe(0x8b);
|
||||
|
||||
// file count covers the regular files we created (3); BSD tar may also
|
||||
// list directory entries, so be generous.
|
||||
expect(r.fileCount).toBeGreaterThanOrEqual(3);
|
||||
expect(r.entries).toContain("a.txt");
|
||||
expect(r.entries).toContain("b.txt");
|
||||
expect(r.entries).toContain("sub");
|
||||
expect(r.entries).toContain("sub/c.txt");
|
||||
expect(r.fileCount).toBe(r.entries?.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirFetch — size cap", () => {
|
||||
it.runIf(HAS_TAR)(
|
||||
"returns TREE_TOO_LARGE when content exceeds the cap mid-stream",
|
||||
async () => {
|
||||
// Write enough random content to exceed a small maxBytes. Random bytes
|
||||
// don't compress, so gzip output is roughly the same size as input.
|
||||
const big = crypto.randomBytes(512 * 1024);
|
||||
await fs.writeFile(path.join(tmpRoot, "big1.bin"), big);
|
||||
await fs.writeFile(path.join(tmpRoot, "big2.bin"), big);
|
||||
await fs.writeFile(path.join(tmpRoot, "big3.bin"), big);
|
||||
|
||||
// 64KB cap should trip either the du preflight or the streaming SIGTERM.
|
||||
await expectDirFetchError({ path: tmpRoot, maxBytes: 64 * 1024 }, "TREE_TOO_LARGE");
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
363
extensions/file-transfer/src/node-host/dir-fetch.ts
Normal file
363
extensions/file-transfer/src/node-host/dir-fetch.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
// File Transfer plugin module implements dir fetch behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
classifyFsSafeReadError,
|
||||
readAbsolutePath,
|
||||
resolveCanonicalReadPath,
|
||||
statRequiredDirectory,
|
||||
} from "./path-errors.js";
|
||||
|
||||
const DIR_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const DIR_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
type DirFetchParams = {
|
||||
path?: unknown;
|
||||
maxBytes?: unknown;
|
||||
includeDotfiles?: unknown;
|
||||
followSymlinks?: unknown;
|
||||
preflightOnly?: unknown;
|
||||
};
|
||||
|
||||
type DirFetchOk = {
|
||||
ok: true;
|
||||
path: string;
|
||||
tarBase64: string;
|
||||
tarBytes: number;
|
||||
sha256: string;
|
||||
fileCount: number;
|
||||
entries?: string[];
|
||||
preflightOnly?: boolean;
|
||||
};
|
||||
|
||||
type DirFetchErrCode =
|
||||
| "INVALID_PATH"
|
||||
| "NOT_FOUND"
|
||||
| "IS_FILE"
|
||||
| "TREE_TOO_LARGE"
|
||||
| "SYMLINK_REDIRECT"
|
||||
| "READ_ERROR";
|
||||
|
||||
type DirFetchErr = {
|
||||
ok: false;
|
||||
code: DirFetchErrCode;
|
||||
message: string;
|
||||
canonicalPath?: string;
|
||||
};
|
||||
|
||||
type DirFetchResult = DirFetchOk | DirFetchErr;
|
||||
|
||||
function clampMaxBytes(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
||||
return DIR_FETCH_DEFAULT_MAX_BYTES;
|
||||
}
|
||||
return Math.min(Math.floor(input), DIR_FETCH_HARD_MAX_BYTES);
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown): DirFetchErrCode {
|
||||
const safeCode = classifyFsSafeReadError(err);
|
||||
if (safeCode) {
|
||||
return safeCode;
|
||||
}
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
return "READ_ERROR";
|
||||
}
|
||||
|
||||
async function preflightDu(dirPath: string, maxBytes: number): Promise<boolean> {
|
||||
// du -sk gives size in 1KB blocks (512-byte blocks on macOS with -k)
|
||||
// We use maxBytes * 4 as the rough heuristic ceiling (generous, gzip compresses)
|
||||
const heuristicKb = Math.ceil((maxBytes * 4) / 1024);
|
||||
return new Promise((resolve) => {
|
||||
const du = spawn("du", ["-sk", dirPath], { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let output = "";
|
||||
du.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
du.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
// du failed; be permissive and let tar catch the overflow
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const match = /^(\d+)/.exec(output.trim());
|
||||
if (!match) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const sizeKb = Number.parseInt(match[1], 10);
|
||||
resolve(sizeKb <= heuristicKb);
|
||||
});
|
||||
du.on("error", () => {
|
||||
// du not available; skip preflight
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function listTarEntries(tarBuffer: Buffer): Promise<string[]> {
|
||||
// Async spawn so a slow `tar -tzf` doesn't park the node-host event
|
||||
// loop for up to 10s. Other in-flight requests continue to be served.
|
||||
return new Promise<string[]>((resolve) => {
|
||||
const child = spawn("tar", ["-tzf", "-"], { stdio: ["pipe", "pipe", "ignore"] });
|
||||
let stdoutBuf = "";
|
||||
let aborted = false;
|
||||
const watchdog = setTimeout(() => {
|
||||
aborted = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
resolve([]);
|
||||
}, 10_000);
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBuf += chunk.toString();
|
||||
// Bound buffer growth — pathological archives shouldn't OOM us.
|
||||
if (stdoutBuf.length > 32 * 1024 * 1024) {
|
||||
aborted = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
clearTimeout(watchdog);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(watchdog);
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const lines = stdoutBuf
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, ""))
|
||||
.filter((line) => line.length > 0);
|
||||
resolve(lines);
|
||||
});
|
||||
child.on("error", () => {
|
||||
clearTimeout(watchdog);
|
||||
if (!aborted) {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
child.stdin.end(tarBuffer);
|
||||
});
|
||||
}
|
||||
|
||||
async function listTreeEntries(root: string, maxEntries: number): Promise<string[] | "TOO_MANY"> {
|
||||
const results: string[] = [];
|
||||
const rootHandle = await fsRoot(root);
|
||||
async function visit(relativeDir: string): Promise<boolean> {
|
||||
const entries = await rootHandle.list(relativeDir, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const rel = path.posix.join(relativeDir === "." ? "" : relativeDir, entry.name);
|
||||
results.push(rel);
|
||||
if (results.length > maxEntries) {
|
||||
return false;
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const ok = await visit(rel);
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return (await visit(".")) ? results : "TOO_MANY";
|
||||
}
|
||||
|
||||
export async function handleDirFetch(params: DirFetchParams): Promise<DirFetchResult> {
|
||||
const requestedPath = readAbsolutePath(params.path);
|
||||
if (typeof requestedPath !== "string") {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
const maxBytes = clampMaxBytes(params.maxBytes);
|
||||
const includeDotfiles = params.includeDotfiles === true;
|
||||
const followSymlinks = params.followSymlinks === true;
|
||||
const preflightOnly = params.preflightOnly === true;
|
||||
|
||||
const canonical = await resolveCanonicalReadPath({
|
||||
requestedPath,
|
||||
followSymlinks,
|
||||
classifyError: classifyFsError,
|
||||
notFoundMessage: "directory not found",
|
||||
});
|
||||
if (typeof canonical !== "string") {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const directory = await statRequiredDirectory(canonical, classifyFsError);
|
||||
if (!directory.ok) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
if (preflightOnly) {
|
||||
try {
|
||||
const entries = await listTreeEntries(canonical, 5000);
|
||||
if (entries === "TOO_MANY") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "TREE_TOO_LARGE",
|
||||
message: "directory tree exceeds 5000 entries during preflight",
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
path: canonical,
|
||||
tarBase64: "",
|
||||
tarBytes: 0,
|
||||
sha256: "",
|
||||
fileCount: entries.length,
|
||||
entries,
|
||||
preflightOnly: true,
|
||||
};
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: `preflight readdir failed: ${String(err)}`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Preflight size check using du
|
||||
const withinBudget = await preflightDu(canonical, maxBytes);
|
||||
if (!withinBudget) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "TREE_TOO_LARGE",
|
||||
message: `directory tree exceeds estimated size limit (${maxBytes} bytes raw)`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
// Build tar args. Shell out to /usr/bin/tar for portability.
|
||||
// -cz: create + gzip
|
||||
// -C <dir>: change to directory so paths in archive are relative
|
||||
// .: include everything from that directory
|
||||
// v1: includeDotfiles is accepted in the API but not enforced. BSD tar's
|
||||
// --exclude pattern matching is unreliable for dotfiles (every plausible
|
||||
// pattern except "*/.*" collapses the archive on macOS). Reliable filtering
|
||||
// requires a `find ! -name '.*' | tar -T -` pipeline; deferred to v2.
|
||||
// For now we always archive everything in the directory.
|
||||
void includeDotfiles;
|
||||
const tarArgs: string[] = ["-czf", "-", "-C", canonical, "."];
|
||||
|
||||
// Capture tar output with a hard byte cap and a wall-clock timeout.
|
||||
// SIGTERM if the byte cap is exceeded; SIGKILL if the timeout fires
|
||||
// (covers tar hanging on a slow filesystem or symlink loop).
|
||||
const TAR_HARD_TIMEOUT_MS = 60_000;
|
||||
const tarBuffer = await new Promise<Buffer | "TOO_LARGE" | "TIMEOUT" | "ERROR">((resolve) => {
|
||||
const tarBin = process.platform !== "win32" ? "/usr/bin/tar" : "tar";
|
||||
const child = spawn(tarBin, tarArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
let aborted = false;
|
||||
|
||||
const watchdog = setTimeout(() => {
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
aborted = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
resolve("TIMEOUT");
|
||||
}, TAR_HARD_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
totalBytes += chunk.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
aborted = true;
|
||||
clearTimeout(watchdog);
|
||||
child.kill("SIGTERM");
|
||||
resolve("TOO_LARGE");
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(watchdog);
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
resolve("ERROR");
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
child.on("error", () => {
|
||||
clearTimeout(watchdog);
|
||||
if (!aborted) {
|
||||
resolve("ERROR");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (tarBuffer === "TOO_LARGE") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "TREE_TOO_LARGE",
|
||||
message: `tarball exceeded ${maxBytes} byte limit mid-stream`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
if (tarBuffer === "TIMEOUT") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "READ_ERROR",
|
||||
message: "tar command exceeded 60s wall-clock timeout (slow filesystem or symlink loop?)",
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
if (tarBuffer === "ERROR") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "READ_ERROR",
|
||||
message: "tar command failed",
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = crypto.createHash("sha256").update(tarBuffer).digest("hex");
|
||||
const tarBase64 = tarBuffer.toString("base64");
|
||||
const tarBytes = tarBuffer.byteLength;
|
||||
const entries = await listTarEntries(tarBuffer);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: canonical,
|
||||
tarBase64,
|
||||
tarBytes,
|
||||
sha256,
|
||||
fileCount: entries.length,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
169
extensions/file-transfer/src/node-host/dir-list.test.ts
Normal file
169
extensions/file-transfer/src/node-host/dir-list.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// File Transfer tests cover dir list plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DIR_LIST_DEFAULT_MAX_ENTRIES,
|
||||
DIR_LIST_HARD_MAX_ENTRIES,
|
||||
handleDirList,
|
||||
} from "./dir-list.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
|
||||
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "dir-list-test-")));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function expectDirListError(
|
||||
input: Parameters<typeof handleDirList>[0],
|
||||
code: "INVALID_PATH" | "IS_FILE" | "NOT_FOUND",
|
||||
) {
|
||||
const result = await handleDirList(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(code);
|
||||
}
|
||||
}
|
||||
|
||||
describe("handleDirList — input validation", () => {
|
||||
it("rejects empty / non-string path", async () => {
|
||||
await expectDirListError({ path: "" }, "INVALID_PATH");
|
||||
await expectDirListError({ path: undefined }, "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects relative paths", async () => {
|
||||
await expectDirListError({ path: "relative" }, "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects paths with NUL bytes", async () => {
|
||||
await expectDirListError({ path: "/tmp/foo\0bar" }, "INVALID_PATH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirList — fs errors", () => {
|
||||
it("returns NOT_FOUND for a missing directory", async () => {
|
||||
await expectDirListError({ path: path.join(tmpRoot, "does-not-exist") }, "NOT_FOUND");
|
||||
});
|
||||
|
||||
it("returns IS_FILE when path resolves to a regular file", async () => {
|
||||
const f = path.join(tmpRoot, "f.txt");
|
||||
await fs.writeFile(f, "x");
|
||||
await expectDirListError({ path: f }, "IS_FILE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirList — happy path", () => {
|
||||
it("lists files and subdirs with metadata, sorted by name", async () => {
|
||||
await fs.writeFile(path.join(tmpRoot, "z.txt"), "Z");
|
||||
await fs.writeFile(path.join(tmpRoot, "a.png"), "PNG-bytes");
|
||||
await fs.mkdir(path.join(tmpRoot, "subdir"));
|
||||
|
||||
const r = await handleDirList({ path: tmpRoot });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
expect(r.entries.map((e) => e.name)).toEqual(["a.png", "subdir", "z.txt"]);
|
||||
|
||||
const a = r.entries.find((e) => e.name === "a.png")!;
|
||||
expect(a.isDir).toBe(false);
|
||||
expect(a.size).toBeGreaterThan(0);
|
||||
expect(a.mimeType).toBe("image/png");
|
||||
|
||||
const sub = r.entries.find((e) => e.name === "subdir")!;
|
||||
expect(sub.isDir).toBe(true);
|
||||
expect(sub.size).toBe(0);
|
||||
expect(sub.mimeType).toBe("inode/directory");
|
||||
|
||||
expect(r.truncated).toBe(false);
|
||||
expect(r.nextPageToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes dotfiles in the listing", async () => {
|
||||
await fs.writeFile(path.join(tmpRoot, ".hidden"), "x");
|
||||
await fs.writeFile(path.join(tmpRoot, "visible"), "x");
|
||||
|
||||
const r = await handleDirList({ path: tmpRoot });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
expect(r.entries.map((e) => e.name)).toEqual([".hidden", "visible"]);
|
||||
});
|
||||
|
||||
it("paginates via pageToken (offset-based)", async () => {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
// zero-pad so localeCompare-stable sort matches creation order
|
||||
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
|
||||
}
|
||||
|
||||
const page1 = await handleDirList({ path: tmpRoot, maxEntries: 3 });
|
||||
if (!page1.ok) {
|
||||
throw new Error("page1");
|
||||
}
|
||||
expect(page1.entries.map((e) => e.name)).toEqual(["f-0.txt", "f-1.txt", "f-2.txt"]);
|
||||
expect(page1.truncated).toBe(true);
|
||||
expect(page1.nextPageToken).toBe("3");
|
||||
|
||||
const page2 = await handleDirList({
|
||||
path: tmpRoot,
|
||||
maxEntries: 3,
|
||||
pageToken: page1.nextPageToken,
|
||||
});
|
||||
if (!page2.ok) {
|
||||
throw new Error("page2");
|
||||
}
|
||||
expect(page2.entries.map((e) => e.name)).toEqual(["f-3.txt", "f-4.txt", "f-5.txt"]);
|
||||
expect(page2.truncated).toBe(true);
|
||||
|
||||
const page3 = await handleDirList({
|
||||
path: tmpRoot,
|
||||
maxEntries: 3,
|
||||
pageToken: page2.nextPageToken,
|
||||
});
|
||||
if (!page3.ok) {
|
||||
throw new Error("page3");
|
||||
}
|
||||
expect(page3.entries.map((e) => e.name)).toEqual(["f-6.txt"]);
|
||||
expect(page3.truncated).toBe(false);
|
||||
expect(page3.nextPageToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not coerce partial page tokens", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
|
||||
}
|
||||
|
||||
const r = await handleDirList({ path: tmpRoot, maxEntries: 1, pageToken: "1next" });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
expect(r.entries.map((e) => e.name)).toEqual(["f-0.txt"]);
|
||||
expect(r.nextPageToken).toBe("1");
|
||||
});
|
||||
|
||||
it("accepts plus-signed page tokens", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await fs.writeFile(path.join(tmpRoot, `f-${i}.txt`), "x");
|
||||
}
|
||||
|
||||
const r = await handleDirList({ path: tmpRoot, maxEntries: 1, pageToken: "+01" });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
expect(r.entries.map((e) => e.name)).toEqual(["f-1.txt"]);
|
||||
expect(r.nextPageToken).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDirList — limits", () => {
|
||||
it("clamps maxEntries to the hard ceiling and uses the default for invalid values", () => {
|
||||
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBe(200);
|
||||
expect(DIR_LIST_HARD_MAX_ENTRIES).toBe(5000);
|
||||
expect(DIR_LIST_DEFAULT_MAX_ENTRIES).toBeLessThan(DIR_LIST_HARD_MAX_ENTRIES);
|
||||
});
|
||||
});
|
||||
155
extensions/file-transfer/src/node-host/dir-list.ts
Normal file
155
extensions/file-transfer/src/node-host/dir-list.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// File Transfer plugin module implements dir list behavior.
|
||||
import path from "node:path";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { root } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { mimeFromExtension } from "../shared/mime.js";
|
||||
import {
|
||||
classifyFsSafeReadError,
|
||||
readAbsolutePath,
|
||||
resolveCanonicalReadPath,
|
||||
statRequiredDirectory,
|
||||
} from "./path-errors.js";
|
||||
|
||||
export const DIR_LIST_DEFAULT_MAX_ENTRIES = 200;
|
||||
export const DIR_LIST_HARD_MAX_ENTRIES = 5000;
|
||||
|
||||
type DirListParams = {
|
||||
path?: unknown;
|
||||
pageToken?: unknown;
|
||||
maxEntries?: unknown;
|
||||
followSymlinks?: unknown;
|
||||
};
|
||||
|
||||
type DirListEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
isDir: boolean;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
type DirListOk = {
|
||||
ok: true;
|
||||
path: string;
|
||||
entries: DirListEntry[];
|
||||
nextPageToken?: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type DirListErrCode =
|
||||
| "INVALID_PATH"
|
||||
| "NOT_FOUND"
|
||||
| "PERMISSION_DENIED"
|
||||
| "IS_FILE"
|
||||
| "SYMLINK_REDIRECT"
|
||||
| "READ_ERROR";
|
||||
|
||||
type DirListErr = {
|
||||
ok: false;
|
||||
code: DirListErrCode;
|
||||
message: string;
|
||||
canonicalPath?: string;
|
||||
};
|
||||
|
||||
type DirListResult = DirListOk | DirListErr;
|
||||
|
||||
function clampMaxEntries(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
||||
return DIR_LIST_DEFAULT_MAX_ENTRIES;
|
||||
}
|
||||
return Math.min(Math.floor(input), DIR_LIST_HARD_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
function parsePageOffset(input: unknown): number {
|
||||
if (typeof input !== "string") {
|
||||
return 0;
|
||||
}
|
||||
return parseStrictNonNegativeInteger(input) ?? 0;
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown): DirListErrCode {
|
||||
const safeCode = classifyFsSafeReadError(err);
|
||||
if (safeCode) {
|
||||
return safeCode;
|
||||
}
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
return "PERMISSION_DENIED";
|
||||
}
|
||||
return "READ_ERROR";
|
||||
}
|
||||
|
||||
export async function handleDirList(params: DirListParams): Promise<DirListResult> {
|
||||
const requestedPath = readAbsolutePath(params.path);
|
||||
if (typeof requestedPath !== "string") {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
const maxEntries = clampMaxEntries(params.maxEntries);
|
||||
const offset = parsePageOffset(params.pageToken);
|
||||
|
||||
const followSymlinks = params.followSymlinks === true;
|
||||
|
||||
const canonical = await resolveCanonicalReadPath({
|
||||
requestedPath,
|
||||
followSymlinks,
|
||||
classifyError: classifyFsError,
|
||||
notFoundMessage: "path not found",
|
||||
});
|
||||
if (typeof canonical !== "string") {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const directory = await statRequiredDirectory(canonical, classifyFsError);
|
||||
if (!directory.ok) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
let listedEntries: { name: string; isDirectory: boolean; size: number; mtimeMs: number }[];
|
||||
try {
|
||||
const dirRoot = await root(canonical);
|
||||
listedEntries = await dirRoot.list(".", { withFileTypes: true });
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: `list failed: ${String(err)}`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
listedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const total = listedEntries.length;
|
||||
const page = listedEntries.slice(offset, offset + maxEntries);
|
||||
const truncated = offset + maxEntries < total;
|
||||
const nextPageToken = truncated ? String(offset + maxEntries) : undefined;
|
||||
|
||||
const entries: DirListEntry[] = [];
|
||||
for (const entry of page) {
|
||||
const entryPath = path.join(canonical, entry.name);
|
||||
const isDir = entry.isDirectory;
|
||||
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path: entryPath,
|
||||
size: isDir ? 0 : entry.size,
|
||||
mimeType: isDir ? "inode/directory" : mimeFromExtension(entry.name),
|
||||
isDir,
|
||||
mtime: entry.mtimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: canonical,
|
||||
entries,
|
||||
nextPageToken,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
255
extensions/file-transfer/src/node-host/file-fetch.test.ts
Normal file
255
extensions/file-transfer/src/node-host/file-fetch.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// File Transfer tests cover file fetch plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FILE_FETCH_DEFAULT_MAX_BYTES,
|
||||
FILE_FETCH_HARD_MAX_BYTES,
|
||||
handleFileFetch,
|
||||
} from "./file-fetch.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
type FileFetchResult = Awaited<ReturnType<typeof handleFileFetch>>;
|
||||
type FileFetchSuccess = Extract<FileFetchResult, { ok: true }>;
|
||||
type FileFetchFailure = Extract<FileFetchResult, { ok: false }>;
|
||||
|
||||
function expectFailureCode(
|
||||
result: FileFetchResult,
|
||||
code: string,
|
||||
): asserts result is FileFetchFailure {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error(`expected failure ${code}`);
|
||||
}
|
||||
expect(result.code).toBe(code);
|
||||
}
|
||||
|
||||
function expectSuccess(result: FileFetchResult): asserts result is FileFetchSuccess {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(`expected ok, got ${result.code}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath the mkdtemp result — on macOS /tmp/foo and /var/folders/... are
|
||||
// symlinks to /private/{tmp,var/folders}, and the new SYMLINK_REDIRECT
|
||||
// default would otherwise refuse every test path. Tests want to exercise
|
||||
// the happy path with canonical paths; symlink-specific assertions create
|
||||
// explicit symlinks inside tmpRoot.
|
||||
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-fetch-test-")));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("handleFileFetch — input validation", () => {
|
||||
it("returns INVALID_PATH for empty / non-string path", async () => {
|
||||
expectFailureCode(await handleFileFetch({ path: "" }), "INVALID_PATH");
|
||||
expectFailureCode(await handleFileFetch({ path: undefined }), "INVALID_PATH");
|
||||
expectFailureCode(await handleFileFetch({ path: 42 as unknown }), "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects relative paths", async () => {
|
||||
const r = await handleFileFetch({ path: "relative/file.txt" });
|
||||
expectFailureCode(r, "INVALID_PATH");
|
||||
expect(r.ok ? "" : r.message).toMatch(/absolute/);
|
||||
});
|
||||
|
||||
it("rejects paths with NUL bytes", async () => {
|
||||
const r = await handleFileFetch({ path: "/tmp/foo\0bar" });
|
||||
expectFailureCode(r, "INVALID_PATH");
|
||||
expect(r.ok ? "" : r.message).toMatch(/NUL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileFetch — fs errors", () => {
|
||||
it("returns NOT_FOUND for a missing file", async () => {
|
||||
const target = path.join(tmpRoot, "missing.txt");
|
||||
expectFailureCode(await handleFileFetch({ path: target }), "NOT_FOUND");
|
||||
});
|
||||
|
||||
it("returns IS_DIRECTORY when the path resolves to a directory", async () => {
|
||||
const r = await handleFileFetch({ path: tmpRoot });
|
||||
expectFailureCode(r, "IS_DIRECTORY");
|
||||
// canonical path is reported back so the caller can re-check policy
|
||||
if (r.ok) {
|
||||
throw new Error("expected directory fetch to fail");
|
||||
}
|
||||
expect(r.canonicalPath).toBe(tmpRoot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileFetch — zero-byte round-trip", () => {
|
||||
it("fetches an empty image-named file with extension-derived MIME", async () => {
|
||||
const target = path.join(tmpRoot, "empty.png");
|
||||
await fs.writeFile(target, "");
|
||||
|
||||
const r = await handleFileFetch({ path: target });
|
||||
if (!r.ok) {
|
||||
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
||||
}
|
||||
expect(r.size).toBe(0);
|
||||
expect(r.mimeType).toBe("image/png");
|
||||
expect(r.base64).toBe("");
|
||||
// SHA-256 of empty input.
|
||||
expect(r.sha256).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileFetch — happy path", () => {
|
||||
it("reads a small file and returns size + sha256 + base64", async () => {
|
||||
const target = path.join(tmpRoot, "hello.txt");
|
||||
const contents = "hello world\n";
|
||||
await fs.writeFile(target, contents);
|
||||
|
||||
const r = await handleFileFetch({ path: target });
|
||||
if (!r.ok) {
|
||||
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
||||
}
|
||||
|
||||
expect(r.size).toBe(contents.length);
|
||||
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
|
||||
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
|
||||
expect(r.sha256).toBe(expectedSha);
|
||||
// canonicalized path may differ from input on macOS (/tmp -> /private/tmp)
|
||||
expect(path.basename(r.path)).toBe("hello.txt");
|
||||
});
|
||||
|
||||
it("preflights canonical path and size without reading bytes", async () => {
|
||||
const target = path.join(tmpRoot, "hello.txt");
|
||||
await fs.writeFile(target, "hello world\n");
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
|
||||
const r = await handleFileFetch({ path: target, preflightOnly: true });
|
||||
|
||||
expectSuccess(r);
|
||||
expect(r.path).toBe(target);
|
||||
expect(r.size).toBe(12);
|
||||
expect(r.base64).toBe("");
|
||||
expect(r.sha256).toBe("");
|
||||
expect(r.preflightOnly).toBe(true);
|
||||
expect(readFileSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a sensible mime type for known extensions", async () => {
|
||||
const target = path.join(tmpRoot, "readme.md");
|
||||
await fs.writeFile(target, "# heading\n");
|
||||
|
||||
const r = await handleFileFetch({ path: target });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
// libmagic ("file" cli) typically reports text/plain or text/markdown for
|
||||
// a one-line markdown file; the extension fallback yields text/markdown.
|
||||
// Accept either.
|
||||
expect(r.mimeType).toMatch(/^text\/(plain|markdown)$/);
|
||||
});
|
||||
|
||||
it("detects extensionless plain text as text/plain", async () => {
|
||||
const target = path.join(tmpRoot, "LICENSE");
|
||||
const contents = "Permission is hereby granted\n";
|
||||
await fs.writeFile(target, contents);
|
||||
|
||||
const r = await handleFileFetch({ path: target });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
|
||||
expect(r.mimeType).toBe("text/plain");
|
||||
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
|
||||
});
|
||||
|
||||
it("does not classify extensionless binary content as text/plain", async () => {
|
||||
const target = path.join(tmpRoot, "opaque");
|
||||
await fs.writeFile(target, Buffer.from([0x00, 0x01, 0x02, 0xff]));
|
||||
|
||||
const r = await handleFileFetch({ path: target });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
|
||||
expect(r.mimeType).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("sniffs binary content instead of trusting a misleading extension", async () => {
|
||||
const target = path.join(tmpRoot, "image.txt");
|
||||
await fs.writeFile(
|
||||
target,
|
||||
Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52,
|
||||
]),
|
||||
);
|
||||
|
||||
const r = await handleFileFetch({ path: target });
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
expect(r.mimeType).toBe("image/png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileFetch — size enforcement", () => {
|
||||
it("returns FILE_TOO_LARGE when stat size exceeds the cap", async () => {
|
||||
const target = path.join(tmpRoot, "big.bin");
|
||||
const data = Buffer.alloc(2048, 0xab);
|
||||
await fs.writeFile(target, data);
|
||||
|
||||
const r = await handleFileFetch({ path: target, maxBytes: 1024 });
|
||||
expectFailureCode(r, "FILE_TOO_LARGE");
|
||||
});
|
||||
|
||||
it("clamps maxBytes to the hard ceiling", async () => {
|
||||
expect(FILE_FETCH_HARD_MAX_BYTES).toBe(16 * 1024 * 1024);
|
||||
expect(FILE_FETCH_DEFAULT_MAX_BYTES).toBeLessThanOrEqual(FILE_FETCH_HARD_MAX_BYTES);
|
||||
|
||||
// A request asking for a maxBytes well above the hard ceiling should
|
||||
// still be honored for a small file (no error).
|
||||
const target = path.join(tmpRoot, "tiny.bin");
|
||||
await fs.writeFile(target, Buffer.from([0x01, 0x02, 0x03]));
|
||||
const r = await handleFileFetch({ path: target, maxBytes: Number.MAX_SAFE_INTEGER });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("uses default cap when maxBytes is not finite or non-positive", async () => {
|
||||
const target = path.join(tmpRoot, "small.bin");
|
||||
await fs.writeFile(target, Buffer.from([0xff]));
|
||||
expectSuccess(await handleFileFetch({ path: target, maxBytes: -1 }));
|
||||
expectSuccess(await handleFileFetch({ path: target, maxBytes: Number.NaN }));
|
||||
expectSuccess(await handleFileFetch({ path: target, maxBytes: "8" as unknown }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileFetch — symlink handling", () => {
|
||||
it("refuses to follow a symlink by default (SYMLINK_REDIRECT)", async () => {
|
||||
const real = path.join(tmpRoot, "real.txt");
|
||||
const link = path.join(tmpRoot, "link.txt");
|
||||
await fs.writeFile(real, "data");
|
||||
await fs.symlink(real, link);
|
||||
|
||||
const r = await handleFileFetch({ path: link });
|
||||
expectFailureCode(r, "SYMLINK_REDIRECT");
|
||||
// Caller learns the canonical target so the operator can update the
|
||||
// allowlist or set followSymlinks=true.
|
||||
expect(r.ok ? null : r.canonicalPath).toBe(real);
|
||||
});
|
||||
|
||||
it("follows symlinks and returns the canonical path when followSymlinks=true", async () => {
|
||||
const real = path.join(tmpRoot, "real.txt");
|
||||
const link = path.join(tmpRoot, "link.txt");
|
||||
await fs.writeFile(real, "data");
|
||||
await fs.symlink(real, link);
|
||||
|
||||
const r = await handleFileFetch({ path: link, followSymlinks: true });
|
||||
if (!r.ok) {
|
||||
throw new Error(`expected ok, got ${r.code}`);
|
||||
}
|
||||
expect(path.basename(r.path)).toBe("real.txt");
|
||||
});
|
||||
});
|
||||
203
extensions/file-transfer/src/node-host/file-fetch.ts
Normal file
203
extensions/file-transfer/src/node-host/file-fetch.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// File Transfer plugin module implements file fetch behavior.
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { detectMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { root } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
classifyFsSafeReadError,
|
||||
readAbsolutePath,
|
||||
resolveCanonicalReadPath,
|
||||
} from "./path-errors.js";
|
||||
|
||||
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
||||
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const TEXT_SNIFF_MAX_BYTES = 8192;
|
||||
|
||||
type FileFetchParams = {
|
||||
path?: unknown;
|
||||
maxBytes?: unknown;
|
||||
followSymlinks?: unknown;
|
||||
preflightOnly?: unknown;
|
||||
};
|
||||
|
||||
type FileFetchOk = {
|
||||
ok: true;
|
||||
path: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
sha256: string;
|
||||
preflightOnly?: boolean;
|
||||
};
|
||||
|
||||
type FileFetchErrCode =
|
||||
| "INVALID_PATH"
|
||||
| "NOT_FOUND"
|
||||
| "PERMISSION_DENIED"
|
||||
| "IS_DIRECTORY"
|
||||
| "FILE_TOO_LARGE"
|
||||
| "PATH_TRAVERSAL"
|
||||
| "SYMLINK_REDIRECT"
|
||||
| "READ_ERROR";
|
||||
|
||||
type FileFetchErr = {
|
||||
ok: false;
|
||||
code: FileFetchErrCode;
|
||||
message: string;
|
||||
canonicalPath?: string;
|
||||
};
|
||||
|
||||
type FileFetchResult = FileFetchOk | FileFetchErr;
|
||||
|
||||
function clampMaxBytes(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
||||
return FILE_FETCH_DEFAULT_MAX_BYTES;
|
||||
}
|
||||
return Math.min(Math.floor(input), FILE_FETCH_HARD_MAX_BYTES);
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown): FileFetchErrCode {
|
||||
const safeCode = classifyFsSafeReadError(err);
|
||||
if (safeCode) {
|
||||
return safeCode;
|
||||
}
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (code === "not-file") {
|
||||
return "IS_DIRECTORY";
|
||||
}
|
||||
if (code === "ENOENT") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
return "PERMISSION_DENIED";
|
||||
}
|
||||
if (code === "EISDIR") {
|
||||
return "IS_DIRECTORY";
|
||||
}
|
||||
return "READ_ERROR";
|
||||
}
|
||||
|
||||
function isLikelyPlainText(buffer: Buffer): boolean {
|
||||
if (buffer.byteLength === 0) {
|
||||
return true;
|
||||
}
|
||||
const sample = buffer.subarray(0, TEXT_SNIFF_MAX_BYTES);
|
||||
if (sample.includes(0)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(sample);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let controlBytes = 0;
|
||||
for (const byte of sample) {
|
||||
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
|
||||
controlBytes += 1;
|
||||
}
|
||||
}
|
||||
return controlBytes / sample.byteLength < 0.01;
|
||||
}
|
||||
|
||||
async function detectFetchedFileMime(params: {
|
||||
buffer: Buffer;
|
||||
filePath: string;
|
||||
}): Promise<string> {
|
||||
const detected = await detectMime(params);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
return isLikelyPlainText(params.buffer) ? "text/plain" : "application/octet-stream";
|
||||
}
|
||||
|
||||
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
|
||||
const requestedPath = readAbsolutePath(params.path);
|
||||
if (typeof requestedPath !== "string") {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
const maxBytes = clampMaxBytes(params.maxBytes);
|
||||
const followSymlinks = params.followSymlinks === true;
|
||||
const preflightOnly = params.preflightOnly === true;
|
||||
|
||||
const canonical = await resolveCanonicalReadPath({
|
||||
requestedPath,
|
||||
followSymlinks,
|
||||
classifyError: classifyFsError,
|
||||
notFoundMessage: "file not found",
|
||||
});
|
||||
if (typeof canonical !== "string") {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
let opened: Awaited<ReturnType<Awaited<ReturnType<typeof root>>["open"]>>;
|
||||
try {
|
||||
const parentRoot = await root(path.dirname(canonical));
|
||||
opened = await parentRoot.open(path.basename(canonical));
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: code === "IS_DIRECTORY" ? "path is a directory" : `open failed: ${String(err)}`,
|
||||
canonicalPath: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = opened.stat;
|
||||
if (stats.size > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "FILE_TOO_LARGE",
|
||||
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
|
||||
canonicalPath: opened.realPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (preflightOnly) {
|
||||
return {
|
||||
ok: true,
|
||||
path: opened.realPath,
|
||||
size: stats.size,
|
||||
mimeType: "",
|
||||
base64: "",
|
||||
sha256: "",
|
||||
preflightOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = await opened.handle.readFile();
|
||||
if (buffer.byteLength > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "FILE_TOO_LARGE",
|
||||
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
|
||||
canonicalPath: opened.realPath,
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const base64 = buffer.toString("base64");
|
||||
const mimeType = await detectFetchedFileMime({ buffer, filePath: opened.realPath });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: opened.realPath,
|
||||
size: buffer.byteLength,
|
||||
mimeType,
|
||||
base64,
|
||||
sha256,
|
||||
};
|
||||
} catch (err) {
|
||||
const code = classifyFsError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: `read failed: ${String(err)}`,
|
||||
canonicalPath: opened.realPath,
|
||||
};
|
||||
} finally {
|
||||
await opened.handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
378
extensions/file-transfer/src/node-host/file-write.test.ts
Normal file
378
extensions/file-transfer/src/node-host/file-write.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
// File Transfer tests cover file write plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { handleFileWrite } from "./file-write.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath: see file-fetch.test.ts for the macOS symlinked-tmpdir reason.
|
||||
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-write-test-")));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function b64(s: string): string {
|
||||
return Buffer.from(s, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
function expectFailure(result: Awaited<ReturnType<typeof handleFileWrite>>, code: string) {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected file write failure");
|
||||
}
|
||||
expect(result.code).toBe(code);
|
||||
}
|
||||
|
||||
function expectSuccessFields(
|
||||
result: Awaited<ReturnType<typeof handleFileWrite>>,
|
||||
fields: Record<string, unknown>,
|
||||
) {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(`expected ok, got ${result.code}: ${result.message}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(result[key as keyof typeof result]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectAccessMissing(target: string) {
|
||||
try {
|
||||
await fs.access(target);
|
||||
} catch (error) {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected ${target} to be missing`);
|
||||
}
|
||||
|
||||
describe("handleFileWrite — input validation", () => {
|
||||
it("rejects empty / non-string path", async () => {
|
||||
expectFailure(await handleFileWrite({ path: "", contentBase64: b64("x") }), "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects relative paths", async () => {
|
||||
const r = await handleFileWrite({ path: "relative.txt", contentBase64: b64("x") });
|
||||
expectFailure(r, "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("rejects paths with NUL bytes", async () => {
|
||||
const r = await handleFileWrite({ path: "/tmp/foo\0bar", contentBase64: b64("x") });
|
||||
expectFailure(r, "INVALID_PATH");
|
||||
});
|
||||
|
||||
it("requires contentBase64 but allows an empty encoded payload", async () => {
|
||||
const missing = await handleFileWrite({ path: path.join(tmpRoot, "missing.bin") });
|
||||
expectFailure(missing, "INVALID_BASE64");
|
||||
|
||||
const target = path.join(tmpRoot, "empty.bin");
|
||||
const empty = await handleFileWrite({ path: target, contentBase64: "" });
|
||||
expectSuccessFields(empty, {
|
||||
size: 0,
|
||||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
});
|
||||
expect(await fs.readFile(target)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — happy path", () => {
|
||||
it("writes a new file and returns size + sha256 + overwritten=false", async () => {
|
||||
const target = path.join(tmpRoot, "out.txt");
|
||||
const contents = "hello write\n";
|
||||
const r = await handleFileWrite({ path: target, contentBase64: b64(contents) });
|
||||
if (!r.ok) {
|
||||
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
||||
}
|
||||
expect(r.size).toBe(contents.length);
|
||||
expect(r.overwritten).toBe(false);
|
||||
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
|
||||
expect(r.sha256).toBe(expectedSha);
|
||||
|
||||
const onDisk = await fs.readFile(target, "utf-8");
|
||||
expect(onDisk).toBe(contents);
|
||||
});
|
||||
|
||||
it("does not leave .tmp files behind on success", async () => {
|
||||
const target = path.join(tmpRoot, "atomic.txt");
|
||||
const r = await handleFileWrite({ path: target, contentBase64: b64("body") });
|
||||
expect(r.ok).toBe(true);
|
||||
|
||||
const entries = await fs.readdir(tmpRoot);
|
||||
const tmpFiles = entries.filter((n) => n.includes(".tmp"));
|
||||
expect(tmpFiles).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — overwrite policy", () => {
|
||||
it("refuses to overwrite an existing file when overwrite=false", async () => {
|
||||
const target = path.join(tmpRoot, "exists.txt");
|
||||
await fs.writeFile(target, "before");
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("after"),
|
||||
overwrite: false,
|
||||
});
|
||||
expectFailure(r, "EXISTS_NO_OVERWRITE");
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("before");
|
||||
});
|
||||
|
||||
it("overwrites and reports overwritten=true when overwrite=true", async () => {
|
||||
const target = path.join(tmpRoot, "exists.txt");
|
||||
await fs.writeFile(target, "before");
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("after"),
|
||||
overwrite: true,
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error("expected ok");
|
||||
}
|
||||
expect(r.overwritten).toBe(true);
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — parent directory handling", () => {
|
||||
it("returns PARENT_NOT_FOUND when parent is missing and createParents=false", async () => {
|
||||
const target = path.join(tmpRoot, "nested", "child.txt");
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("x"),
|
||||
createParents: false,
|
||||
});
|
||||
expectFailure(r, "PARENT_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("creates missing parents when createParents=true", async () => {
|
||||
const target = path.join(tmpRoot, "deep", "nested", "child.txt");
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("x"),
|
||||
createParents: true,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — symlink protection", () => {
|
||||
it("refuses to write through an existing symlink (lstat)", async () => {
|
||||
const real = path.join(tmpRoot, "real.txt");
|
||||
const link = path.join(tmpRoot, "link.txt");
|
||||
await fs.writeFile(real, "untouched");
|
||||
await fs.symlink(real, link);
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: link,
|
||||
contentBase64: b64("evil"),
|
||||
overwrite: true,
|
||||
});
|
||||
expectFailure(r, "SYMLINK_TARGET_DENIED");
|
||||
// The original file must be unchanged.
|
||||
expect(await fs.readFile(real, "utf-8")).toBe("untouched");
|
||||
});
|
||||
|
||||
it("refuses to write through a symlink in a parent directory by default", async () => {
|
||||
// realDir is the actual victim; sentinel is a pre-existing file in it.
|
||||
const realDir = path.join(tmpRoot, "real-dir");
|
||||
await fs.mkdir(realDir);
|
||||
const sentinel = path.join(realDir, "sentinel.txt");
|
||||
await fs.writeFile(sentinel, "DO_NOT_TOUCH");
|
||||
|
||||
// /tmpRoot/allowed -> /tmpRoot/real-dir (symlink in a parent segment).
|
||||
const allowed = path.join(tmpRoot, "allowed");
|
||||
await fs.symlink(realDir, allowed);
|
||||
|
||||
// Asking to write to .../allowed/new-file.txt — the lexical parent
|
||||
// (.../allowed) resolves through a symlink to .../real-dir. Refuse.
|
||||
const r = await handleFileWrite({
|
||||
path: path.join(allowed, "new-file.txt"),
|
||||
contentBase64: b64("payload"),
|
||||
});
|
||||
expectFailure(r, "SYMLINK_REDIRECT");
|
||||
// The error includes the canonical target so the operator can
|
||||
// either update allowWritePaths or set followSymlinks=true.
|
||||
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new-file.txt"));
|
||||
// No file was created at the canonical target.
|
||||
await expectAccessMissing(path.join(realDir, "new-file.txt"));
|
||||
// Sentinel must be untouched.
|
||||
expect(await fs.readFile(sentinel, "utf-8")).toBe("DO_NOT_TOUCH");
|
||||
});
|
||||
|
||||
it("checks symlinked parents before recursive mkdir", async () => {
|
||||
const realDir = path.join(tmpRoot, "real-dir");
|
||||
await fs.mkdir(realDir);
|
||||
const allowed = path.join(tmpRoot, "allowed");
|
||||
await fs.symlink(realDir, allowed);
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: path.join(allowed, "new", "child.txt"),
|
||||
contentBase64: b64("payload"),
|
||||
createParents: true,
|
||||
});
|
||||
|
||||
expectFailure(r, "SYMLINK_REDIRECT");
|
||||
expect(r.ok ? null : r.canonicalPath).toBe(path.join(realDir, "new", "child.txt"));
|
||||
await expectAccessMissing(path.join(realDir, "new"));
|
||||
});
|
||||
|
||||
it("follows the parent symlink when followSymlinks=true", async () => {
|
||||
const realDir = path.join(tmpRoot, "real-dir");
|
||||
await fs.mkdir(realDir);
|
||||
const allowed = path.join(tmpRoot, "allowed");
|
||||
await fs.symlink(realDir, allowed);
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: path.join(allowed, "new-file.txt"),
|
||||
contentBase64: b64("payload"),
|
||||
followSymlinks: true,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
// The file landed in the canonical (real) directory.
|
||||
expect(await fs.readFile(path.join(realDir, "new-file.txt"), "utf-8")).toBe("payload");
|
||||
});
|
||||
|
||||
it("preflights canonical write targets without creating files or parents", async () => {
|
||||
const realDir = path.join(tmpRoot, "real-dir");
|
||||
await fs.mkdir(realDir);
|
||||
const allowed = path.join(tmpRoot, "allowed");
|
||||
await fs.symlink(realDir, allowed);
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: path.join(allowed, "new", "child.txt"),
|
||||
contentBase64: b64("payload"),
|
||||
createParents: true,
|
||||
followSymlinks: true,
|
||||
preflightOnly: true,
|
||||
});
|
||||
|
||||
expectSuccessFields(r, {
|
||||
path: path.join(realDir, "new", "child.txt"),
|
||||
size: "payload".length,
|
||||
});
|
||||
await expectAccessMissing(path.join(realDir, "new"));
|
||||
});
|
||||
|
||||
it("refuses to overwrite a directory", async () => {
|
||||
const target = path.join(tmpRoot, "is-a-dir");
|
||||
await fs.mkdir(target);
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("x"),
|
||||
overwrite: true,
|
||||
});
|
||||
expectFailure(r, "IS_DIRECTORY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — integrity check", () => {
|
||||
it("returns INTEGRITY_FAILURE before writing when expectedSha256 mismatches", async () => {
|
||||
const target = path.join(tmpRoot, "checked.txt");
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("real-content"),
|
||||
expectedSha256: "0".repeat(64),
|
||||
});
|
||||
expectFailure(r, "INTEGRITY_FAILURE");
|
||||
// The file must never be created on a mismatch.
|
||||
await expectAccessMissing(target);
|
||||
});
|
||||
|
||||
it("does NOT replace or delete an existing file when overwrite=true and expectedSha256 mismatches", async () => {
|
||||
const target = path.join(tmpRoot, "victim.txt");
|
||||
await fs.writeFile(target, "ORIGINAL_CONTENT_DO_NOT_TOUCH");
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64("attacker-content"),
|
||||
overwrite: true,
|
||||
expectedSha256: "0".repeat(64),
|
||||
});
|
||||
expectFailure(r, "INTEGRITY_FAILURE");
|
||||
// Critical: the original must survive. A bad caller hash must not
|
||||
// be a primitive for replacing-then-deleting an existing file.
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("ORIGINAL_CONTENT_DO_NOT_TOUCH");
|
||||
});
|
||||
|
||||
it("accepts a matching expectedSha256 and keeps the file", async () => {
|
||||
const target = path.join(tmpRoot, "checked.txt");
|
||||
const contents = "real-content";
|
||||
const sha = crypto.createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64(contents),
|
||||
expectedSha256: sha,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await fs.readFile(target, "utf-8")).toBe(contents);
|
||||
});
|
||||
|
||||
it("treats expectedSha256 as case-insensitive", async () => {
|
||||
const target = path.join(tmpRoot, "checked.txt");
|
||||
const contents = "abc";
|
||||
const sha = crypto.createHash("sha256").update(contents).digest("hex").toUpperCase();
|
||||
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: b64(contents),
|
||||
expectedSha256: sha,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — base64 round-trip validation", () => {
|
||||
it("rejects malformed base64 that silently drops characters", async () => {
|
||||
const target = path.join(tmpRoot, "bad.bin");
|
||||
// "@" is not in the base64 alphabet — Buffer.from would silently drop
|
||||
// it and decode "AAA" instead of failing.
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: "AAA@@@",
|
||||
});
|
||||
expectFailure(r, "INVALID_BASE64");
|
||||
await expectAccessMissing(target);
|
||||
});
|
||||
|
||||
it("accepts standard base64 with and without padding", async () => {
|
||||
const target = path.join(tmpRoot, "padded.bin");
|
||||
// Buffer.from("hi") -> "aGk=" with padding, "aGk" without.
|
||||
const r1 = await handleFileWrite({ path: target, contentBase64: "aGk=" });
|
||||
expect(r1.ok).toBe(true);
|
||||
|
||||
const target2 = path.join(tmpRoot, "unpadded.bin");
|
||||
const r2 = await handleFileWrite({ path: target2, contentBase64: "aGk" });
|
||||
expect(r2.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts base64url variant (-_ instead of +/)", async () => {
|
||||
const target = path.join(tmpRoot, "url.bin");
|
||||
// Buffer.from([0xfb, 0xff]) -> "+/8=" standard, "-_8=" url
|
||||
const r = await handleFileWrite({ path: target, contentBase64: "-_8=" });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFileWrite — size cap", () => {
|
||||
it("rejects content larger than the 16MB cap", async () => {
|
||||
const target = path.join(tmpRoot, "big.bin");
|
||||
// 17MB of zero-bytes — base64 inflates by ~4/3 but we're checking the
|
||||
// decoded buffer length so this is fine.
|
||||
const big = Buffer.alloc(17 * 1024 * 1024, 0);
|
||||
const r = await handleFileWrite({
|
||||
path: target,
|
||||
contentBase64: big.toString("base64"),
|
||||
});
|
||||
expectFailure(r, "FILE_TOO_LARGE");
|
||||
});
|
||||
});
|
||||
280
extensions/file-transfer/src/node-host/file-write.ts
Normal file
280
extensions/file-transfer/src/node-host/file-write.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// File Transfer plugin module implements file write behavior.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
canonicalPathFromExistingAncestor,
|
||||
FsSafeError,
|
||||
resolveAbsolutePathForWrite,
|
||||
root,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
const MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16 MB
|
||||
|
||||
type FileWriteParams = {
|
||||
path: string;
|
||||
contentBase64: string;
|
||||
overwrite: boolean;
|
||||
createParents: boolean;
|
||||
expectedSha256?: string;
|
||||
followSymlinks?: boolean;
|
||||
preflightOnly?: boolean;
|
||||
};
|
||||
|
||||
type FileWriteSuccess = {
|
||||
ok: true;
|
||||
path: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
overwritten: boolean;
|
||||
};
|
||||
|
||||
type FileWriteError = {
|
||||
ok: false;
|
||||
code: string;
|
||||
message: string;
|
||||
canonicalPath?: string;
|
||||
};
|
||||
|
||||
type FileWriteResult = FileWriteSuccess | FileWriteError;
|
||||
|
||||
function sha256Hex(buf: Buffer): string {
|
||||
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
function err(code: string, message: string, canonicalPath?: string): FileWriteError {
|
||||
return { ok: false, code, message, ...(canonicalPath ? { canonicalPath } : {}) };
|
||||
}
|
||||
|
||||
function symlinkRedirectError(error: FsSafeError): FileWriteError {
|
||||
const canonicalTarget =
|
||||
error.cause &&
|
||||
typeof error.cause === "object" &&
|
||||
"canonicalPath" in error.cause &&
|
||||
typeof error.cause.canonicalPath === "string"
|
||||
? error.cause.canonicalPath
|
||||
: undefined;
|
||||
return err(
|
||||
"SYMLINK_REDIRECT",
|
||||
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowWritePaths to the canonical path)",
|
||||
canonicalTarget,
|
||||
);
|
||||
}
|
||||
|
||||
function writeFsSafeError(error: FsSafeError, targetPath: string): FileWriteError {
|
||||
if (error.code === "symlink") {
|
||||
return err(
|
||||
"SYMLINK_TARGET_DENIED",
|
||||
`path is a symlink; refusing to write through it: ${targetPath}`,
|
||||
);
|
||||
}
|
||||
if (error.code === "not-file") {
|
||||
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
|
||||
}
|
||||
if (error.code === "already-exists") {
|
||||
return err("EXISTS_NO_OVERWRITE", `file already exists and overwrite is false: ${targetPath}`);
|
||||
}
|
||||
return err("WRITE_ERROR", error.message, targetPath);
|
||||
}
|
||||
|
||||
export async function handleFileWrite(
|
||||
params: Partial<FileWriteParams> & Record<string, unknown>,
|
||||
): Promise<FileWriteResult> {
|
||||
const rawPath = typeof params?.path === "string" ? params.path : "";
|
||||
const hasContentBase64 = typeof params?.contentBase64 === "string";
|
||||
const contentBase64 = hasContentBase64 ? (params.contentBase64 as string) : "";
|
||||
const overwrite = params?.overwrite === true;
|
||||
const createParents = params?.createParents === true;
|
||||
const expectedSha256 =
|
||||
typeof params?.expectedSha256 === "string" ? params.expectedSha256 : undefined;
|
||||
const followSymlinks = params?.followSymlinks === true;
|
||||
const preflightOnly = params?.preflightOnly === true;
|
||||
|
||||
// 1. Validate path: must be absolute, non-empty, no NUL byte
|
||||
if (!rawPath) {
|
||||
return err("INVALID_PATH", "path is required");
|
||||
}
|
||||
if (rawPath.includes("\0")) {
|
||||
return err("INVALID_PATH", "path must not contain NUL bytes");
|
||||
}
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
return err("INVALID_PATH", "path must be absolute");
|
||||
}
|
||||
if (!hasContentBase64) {
|
||||
return err("INVALID_BASE64", "contentBase64 is required");
|
||||
}
|
||||
|
||||
// 2. Decode base64 → Buffer.
|
||||
// Buffer.from(s, "base64") in Node never throws — it silently drops
|
||||
// non-base64 characters and returns whatever it could decode. That
|
||||
// means a typo or truncated input would land garbage on disk if we
|
||||
// accepted whatever decoded. Defense: round-trip the decoded buffer
|
||||
// back to base64 and compare against the input modulo padding/url
|
||||
// variants. A mismatch means characters were silently dropped.
|
||||
const buf = Buffer.from(contentBase64, "base64");
|
||||
const reEncoded = buf.toString("base64");
|
||||
// Normalize: drop padding and convert base64url chars to standard so the
|
||||
// comparison tolerates both "=" / no-"=" inputs and "-_" base64url.
|
||||
const normalize = (s: string): string =>
|
||||
s.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
|
||||
if (normalize(reEncoded) !== normalize(contentBase64)) {
|
||||
return err("INVALID_BASE64", "contentBase64 is not valid base64");
|
||||
}
|
||||
|
||||
if (buf.length > MAX_CONTENT_BYTES) {
|
||||
return err(
|
||||
"FILE_TOO_LARGE",
|
||||
`decoded content is ${buf.length} bytes; maximum is ${MAX_CONTENT_BYTES} bytes (16 MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
let targetPath: string;
|
||||
let parentDir: string;
|
||||
let parentExists: boolean;
|
||||
try {
|
||||
const resolved = await resolveAbsolutePathForWrite(rawPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
});
|
||||
targetPath = resolved.path;
|
||||
parentDir = resolved.parentDir;
|
||||
parentExists = resolved.parentExists;
|
||||
} catch (error) {
|
||||
if (error instanceof FsSafeError && error.code === "symlink") {
|
||||
return symlinkRedirectError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!parentExists) {
|
||||
if (!createParents) {
|
||||
return err("PARENT_NOT_FOUND", `parent directory does not exist: ${parentDir}`);
|
||||
}
|
||||
if (preflightOnly) {
|
||||
const computedSha256 = sha256Hex(buf);
|
||||
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
|
||||
return err(
|
||||
"INTEGRITY_FAILURE",
|
||||
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
|
||||
targetPath,
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
path: await canonicalPathFromExistingAncestor(targetPath),
|
||||
size: buf.length,
|
||||
sha256: computedSha256,
|
||||
overwritten: false,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
} catch (mkdirErr) {
|
||||
const message = mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr);
|
||||
return err("WRITE_ERROR", `failed to create parent directories: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await resolveAbsolutePathForWrite(targetPath, {
|
||||
symlinks: followSymlinks ? "follow" : "reject",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FsSafeError && error.code === "symlink") {
|
||||
return symlinkRedirectError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetFileName = path.basename(targetPath);
|
||||
const parentRoot = await root(parentDir);
|
||||
let overwritten = false;
|
||||
try {
|
||||
const existingLStat = await fs.lstat(targetPath);
|
||||
if (existingLStat.isSymbolicLink()) {
|
||||
return err(
|
||||
"SYMLINK_TARGET_DENIED",
|
||||
`path is a symlink; refusing to write through it: ${targetPath}`,
|
||||
);
|
||||
}
|
||||
if (existingLStat.isDirectory()) {
|
||||
return err("IS_DIRECTORY", `path resolves to a directory: ${targetPath}`);
|
||||
}
|
||||
if (!overwrite) {
|
||||
return err(
|
||||
"EXISTS_NO_OVERWRITE",
|
||||
`file already exists and overwrite is false: ${targetPath}`,
|
||||
);
|
||||
}
|
||||
overwritten = true;
|
||||
} catch (statErr: unknown) {
|
||||
const statErrorCode =
|
||||
statErr instanceof FsSafeError ? statErr.code : (statErr as NodeJS.ErrnoException).code;
|
||||
if (statErrorCode !== "not-found" && statErrorCode !== "ENOENT") {
|
||||
const message = statErr instanceof Error ? statErr.message : String(statErr);
|
||||
if (message.toLowerCase().includes("permission")) {
|
||||
return err("PERMISSION_DENIED", `permission denied: ${targetPath}`);
|
||||
}
|
||||
return err("WRITE_ERROR", `unexpected stat error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Hash the decoded buffer BEFORE touching disk. If the caller
|
||||
// supplied expectedSha256 and it doesn't match, refuse outright so
|
||||
// a bad caller hash with overwrite=true can't replace + delete the
|
||||
// original. Computing from the buffer (not a re-read) is the right
|
||||
// source of truth — the caller asked us to write THESE bytes.
|
||||
const computedSha256 = sha256Hex(buf);
|
||||
if (expectedSha256 && expectedSha256.toLowerCase() !== computedSha256) {
|
||||
return err(
|
||||
"INTEGRITY_FAILURE",
|
||||
`sha256 mismatch: expected ${expectedSha256.toLowerCase()}, got ${computedSha256}`,
|
||||
targetPath,
|
||||
);
|
||||
}
|
||||
|
||||
if (preflightOnly) {
|
||||
return {
|
||||
ok: true,
|
||||
path: await canonicalPathFromExistingAncestor(targetPath),
|
||||
size: buf.length,
|
||||
sha256: computedSha256,
|
||||
overwritten,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (overwrite) {
|
||||
await parentRoot.write(targetFileName, buf);
|
||||
} else {
|
||||
await parentRoot.create(targetFileName, buf);
|
||||
}
|
||||
} catch (writeErr) {
|
||||
if (writeErr instanceof FsSafeError) {
|
||||
return writeFsSafeError(writeErr, targetPath);
|
||||
}
|
||||
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
||||
if (message.toLowerCase().includes("permission") || message.toLowerCase().includes("access")) {
|
||||
return err("PERMISSION_DENIED", `permission denied writing to: ${parentDir}`);
|
||||
}
|
||||
return err("WRITE_ERROR", `failed to write file: ${message}`);
|
||||
}
|
||||
|
||||
let canonicalPath = targetPath;
|
||||
try {
|
||||
const opened = await parentRoot.open(targetFileName);
|
||||
canonicalPath = opened.realPath;
|
||||
await opened.handle.close().catch(() => undefined);
|
||||
} catch (openErr) {
|
||||
if (openErr instanceof FsSafeError) {
|
||||
return writeFsSafeError(openErr, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: canonicalPath,
|
||||
size: buf.length,
|
||||
sha256: computedSha256,
|
||||
overwritten,
|
||||
};
|
||||
}
|
||||
112
extensions/file-transfer/src/node-host/path-errors.ts
Normal file
112
extensions/file-transfer/src/node-host/path-errors.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// File Transfer plugin module implements path errors behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { FsSafeError, resolveAbsolutePathForRead } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export type InvalidPathResult = {
|
||||
ok: false;
|
||||
code: "INVALID_PATH";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const SYMLINK_REJECTED_MESSAGE =
|
||||
"path traverses a symlink; refusing because followSymlinks=false (set plugins.entries.file-transfer.config.nodes.<node>.followSymlinks=true to allow, or update allowReadPaths to the canonical path)";
|
||||
|
||||
export type FsSafeReadErrorCode = "INVALID_PATH" | "NOT_FOUND" | "SYMLINK_REDIRECT";
|
||||
|
||||
export function classifyFsSafeReadError(err: unknown): FsSafeReadErrorCode | undefined {
|
||||
if (!(err instanceof FsSafeError)) {
|
||||
return undefined;
|
||||
}
|
||||
if (err.code === "not-found") {
|
||||
return "NOT_FOUND";
|
||||
}
|
||||
if (err.code === "symlink") {
|
||||
return "SYMLINK_REDIRECT";
|
||||
}
|
||||
if (err.code === "invalid-path") {
|
||||
return "INVALID_PATH";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readAbsolutePath(input: unknown): string | InvalidPathResult {
|
||||
if (typeof input !== "string" || input.length === 0) {
|
||||
return { ok: false, code: "INVALID_PATH", message: "path required" };
|
||||
}
|
||||
if (input.includes("\0")) {
|
||||
return { ok: false, code: "INVALID_PATH", message: "path contains NUL byte" };
|
||||
}
|
||||
if (!path.isAbsolute(input)) {
|
||||
return { ok: false, code: "INVALID_PATH", message: "path must be absolute" };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function canonicalPathFromFsSafeError(err: unknown): string | undefined {
|
||||
if (!(err instanceof FsSafeError) || !err.cause || typeof err.cause !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return "canonicalPath" in err.cause && typeof err.cause.canonicalPath === "string"
|
||||
? err.cause.canonicalPath
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function resolveCanonicalReadPath<Code extends string>(input: {
|
||||
classifyError: (err: unknown) => Code;
|
||||
followSymlinks: boolean;
|
||||
notFoundMessage: string;
|
||||
requestedPath: string;
|
||||
}): Promise<string | { ok: false; code: Code; message: string; canonicalPath?: string }> {
|
||||
try {
|
||||
return (
|
||||
await resolveAbsolutePathForRead(input.requestedPath, {
|
||||
symlinks: input.followSymlinks ? "follow" : "reject",
|
||||
})
|
||||
).canonicalPath;
|
||||
} catch (err) {
|
||||
const code = input.classifyError(err);
|
||||
const canonicalPath = canonicalPathFromFsSafeError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message:
|
||||
code === "NOT_FOUND"
|
||||
? input.notFoundMessage
|
||||
: code === "SYMLINK_REDIRECT"
|
||||
? SYMLINK_REJECTED_MESSAGE
|
||||
: `realpath failed: ${String(err)}`,
|
||||
...(canonicalPath ? { canonicalPath } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function statRequiredDirectory<Code extends string>(
|
||||
canonicalPath: string,
|
||||
classifyError: (err: unknown) => Code,
|
||||
): Promise<
|
||||
{ ok: true } | { ok: false; code: Code | "IS_FILE"; message: string; canonicalPath: string }
|
||||
> {
|
||||
let stats: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stats = await fs.stat(canonicalPath);
|
||||
} catch (err) {
|
||||
const code = classifyError(err);
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: `stat failed: ${String(err)}`,
|
||||
canonicalPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "IS_FILE",
|
||||
message: "path is not a directory",
|
||||
canonicalPath,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user