@actagent/file-transfer 2026.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/actagent.plugin.json +50 -0
- package/index.test.ts +93 -0
- package/index.ts +121 -0
- package/package.json +18 -0
- package/src/node-host/dir-fetch.test.ts +131 -0
- package/src/node-host/dir-fetch.ts +363 -0
- package/src/node-host/dir-list.test.ts +169 -0
- package/src/node-host/dir-list.ts +155 -0
- package/src/node-host/file-fetch.test.ts +254 -0
- package/src/node-host/file-fetch.ts +203 -0
- package/src/node-host/file-write.test.ts +378 -0
- package/src/node-host/file-write.ts +280 -0
- package/src/node-host/path-errors.ts +112 -0
- package/src/shared/audit.ts +98 -0
- package/src/shared/errors.test.ts +63 -0
- package/src/shared/errors.ts +68 -0
- package/src/shared/lazy-node-invoke-policy.test.ts +102 -0
- package/src/shared/lazy-node-invoke-policy.ts +36 -0
- package/src/shared/mime.test.ts +61 -0
- package/src/shared/mime.ts +30 -0
- package/src/shared/node-invoke-policy-commands.ts +9 -0
- package/src/shared/node-invoke-policy.test.ts +763 -0
- package/src/shared/node-invoke-policy.ts +947 -0
- package/src/shared/params.test.ts +42 -0
- package/src/shared/params.ts +60 -0
- package/src/shared/policy.test.ts +568 -0
- package/src/shared/policy.ts +383 -0
- package/src/tools/descriptors.ts +145 -0
- package/src/tools/dir-fetch-tool.test.ts +194 -0
- package/src/tools/dir-fetch-tool.ts +660 -0
- package/src/tools/dir-list-tool.ts +79 -0
- package/src/tools/file-fetch-tool.test.ts +82 -0
- package/src/tools/file-fetch-tool.ts +133 -0
- package/src/tools/file-write-tool.test.ts +30 -0
- package/src/tools/file-write-tool.ts +122 -0
- package/src/tools/node-tool-invoke.ts +97 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// File Transfer tests cover file fetch plugin behavior.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
FILE_FETCH_DEFAULT_MAX_BYTES,
|
|
9
|
+
FILE_FETCH_HARD_MAX_BYTES,
|
|
10
|
+
handleFileFetch,
|
|
11
|
+
} from "./file-fetch.js";
|
|
12
|
+
|
|
13
|
+
let tmpRoot: string;
|
|
14
|
+
|
|
15
|
+
type FileFetchResult = Awaited<ReturnType<typeof handleFileFetch>>;
|
|
16
|
+
type FileFetchSuccess = Extract<FileFetchResult, { ok: true }>;
|
|
17
|
+
type FileFetchFailure = Extract<FileFetchResult, { ok: false }>;
|
|
18
|
+
|
|
19
|
+
function expectFailureCode(
|
|
20
|
+
result: FileFetchResult,
|
|
21
|
+
code: string,
|
|
22
|
+
): asserts result is FileFetchFailure {
|
|
23
|
+
expect(result.ok).toBe(false);
|
|
24
|
+
if (result.ok) {
|
|
25
|
+
throw new Error(`expected failure ${code}`);
|
|
26
|
+
}
|
|
27
|
+
expect(result.code).toBe(code);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expectSuccess(result: FileFetchResult): asserts result is FileFetchSuccess {
|
|
31
|
+
expect(result.ok).toBe(true);
|
|
32
|
+
if (!result.ok) {
|
|
33
|
+
throw new Error(`expected ok, got ${result.code}: ${result.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
// realpath the mkdtemp result — on macOS /tmp/foo and /var/folders/... are
|
|
39
|
+
// symlinks to /private/{tmp,var/folders}, and the new SYMLINK_REDIRECT
|
|
40
|
+
// default would otherwise refuse every test path. Tests want to exercise
|
|
41
|
+
// the happy path with canonical paths; symlink-specific assertions create
|
|
42
|
+
// explicit symlinks inside tmpRoot.
|
|
43
|
+
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "file-fetch-test-")));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("handleFileFetch — input validation", () => {
|
|
52
|
+
it("returns INVALID_PATH for empty / non-string path", async () => {
|
|
53
|
+
expectFailureCode(await handleFileFetch({ path: "" }), "INVALID_PATH");
|
|
54
|
+
expectFailureCode(await handleFileFetch({ path: undefined }), "INVALID_PATH");
|
|
55
|
+
expectFailureCode(await handleFileFetch({ path: 42 as unknown }), "INVALID_PATH");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects relative paths", async () => {
|
|
59
|
+
const r = await handleFileFetch({ path: "relative/file.txt" });
|
|
60
|
+
expectFailureCode(r, "INVALID_PATH");
|
|
61
|
+
expect(r.ok ? "" : r.message).toMatch(/absolute/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects paths with NUL bytes", async () => {
|
|
65
|
+
const r = await handleFileFetch({ path: "/tmp/foo\0bar" });
|
|
66
|
+
expectFailureCode(r, "INVALID_PATH");
|
|
67
|
+
expect(r.ok ? "" : r.message).toMatch(/NUL/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("handleFileFetch — fs errors", () => {
|
|
72
|
+
it("returns NOT_FOUND for a missing file", async () => {
|
|
73
|
+
const target = path.join(tmpRoot, "missing.txt");
|
|
74
|
+
expectFailureCode(await handleFileFetch({ path: target }), "NOT_FOUND");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns IS_DIRECTORY when the path resolves to a directory", async () => {
|
|
78
|
+
const r = await handleFileFetch({ path: tmpRoot });
|
|
79
|
+
expectFailureCode(r, "IS_DIRECTORY");
|
|
80
|
+
// canonical path is reported back so the caller can re-check policy
|
|
81
|
+
if (r.ok) {
|
|
82
|
+
throw new Error("expected directory fetch to fail");
|
|
83
|
+
}
|
|
84
|
+
expect(r.canonicalPath).toBe(tmpRoot);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("handleFileFetch — zero-byte round-trip", () => {
|
|
89
|
+
it("fetches an empty file with size=0 and base64=''", async () => {
|
|
90
|
+
const target = path.join(tmpRoot, "empty.bin");
|
|
91
|
+
await fs.writeFile(target, "");
|
|
92
|
+
|
|
93
|
+
const r = await handleFileFetch({ path: target });
|
|
94
|
+
if (!r.ok) {
|
|
95
|
+
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
|
96
|
+
}
|
|
97
|
+
expect(r.size).toBe(0);
|
|
98
|
+
expect(r.base64).toBe("");
|
|
99
|
+
// SHA-256 of empty input.
|
|
100
|
+
expect(r.sha256).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("handleFileFetch — happy path", () => {
|
|
105
|
+
it("reads a small file and returns size + sha256 + base64", async () => {
|
|
106
|
+
const target = path.join(tmpRoot, "hello.txt");
|
|
107
|
+
const contents = "hello world\n";
|
|
108
|
+
await fs.writeFile(target, contents);
|
|
109
|
+
|
|
110
|
+
const r = await handleFileFetch({ path: target });
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
throw new Error(`expected ok, got ${r.code}: ${r.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
expect(r.size).toBe(contents.length);
|
|
116
|
+
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
|
|
117
|
+
const expectedSha = crypto.createHash("sha256").update(contents).digest("hex");
|
|
118
|
+
expect(r.sha256).toBe(expectedSha);
|
|
119
|
+
// canonicalized path may differ from input on macOS (/tmp -> /private/tmp)
|
|
120
|
+
expect(path.basename(r.path)).toBe("hello.txt");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("preflights canonical path and size without reading bytes", async () => {
|
|
124
|
+
const target = path.join(tmpRoot, "hello.txt");
|
|
125
|
+
await fs.writeFile(target, "hello world\n");
|
|
126
|
+
const readFileSpy = vi.spyOn(fs, "readFile");
|
|
127
|
+
|
|
128
|
+
const r = await handleFileFetch({ path: target, preflightOnly: true });
|
|
129
|
+
|
|
130
|
+
expectSuccess(r);
|
|
131
|
+
expect(r.path).toBe(target);
|
|
132
|
+
expect(r.size).toBe(12);
|
|
133
|
+
expect(r.base64).toBe("");
|
|
134
|
+
expect(r.sha256).toBe("");
|
|
135
|
+
expect(r.preflightOnly).toBe(true);
|
|
136
|
+
expect(readFileSpy).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns a sensible mime type for known extensions", async () => {
|
|
140
|
+
const target = path.join(tmpRoot, "readme.md");
|
|
141
|
+
await fs.writeFile(target, "# heading\n");
|
|
142
|
+
|
|
143
|
+
const r = await handleFileFetch({ path: target });
|
|
144
|
+
if (!r.ok) {
|
|
145
|
+
throw new Error("expected ok");
|
|
146
|
+
}
|
|
147
|
+
// libmagic ("file" cli) typically reports text/plain or text/markdown for
|
|
148
|
+
// a one-line markdown file; the extension fallback yields text/markdown.
|
|
149
|
+
// Accept either.
|
|
150
|
+
expect(r.mimeType).toMatch(/^text\/(plain|markdown)$/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("detects extensionless plain text as text/plain", async () => {
|
|
154
|
+
const target = path.join(tmpRoot, "LICENSE");
|
|
155
|
+
const contents = "Permission is hereby granted\n";
|
|
156
|
+
await fs.writeFile(target, contents);
|
|
157
|
+
|
|
158
|
+
const r = await handleFileFetch({ path: target });
|
|
159
|
+
if (!r.ok) {
|
|
160
|
+
throw new Error("expected ok");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(r.mimeType).toBe("text/plain");
|
|
164
|
+
expect(Buffer.from(r.base64, "base64").toString("utf-8")).toBe(contents);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not classify extensionless binary content as text/plain", async () => {
|
|
168
|
+
const target = path.join(tmpRoot, "opaque");
|
|
169
|
+
await fs.writeFile(target, Buffer.from([0x00, 0x01, 0x02, 0xff]));
|
|
170
|
+
|
|
171
|
+
const r = await handleFileFetch({ path: target });
|
|
172
|
+
if (!r.ok) {
|
|
173
|
+
throw new Error("expected ok");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
expect(r.mimeType).toBe("application/octet-stream");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("sniffs binary content instead of trusting a misleading extension", async () => {
|
|
180
|
+
const target = path.join(tmpRoot, "image.txt");
|
|
181
|
+
await fs.writeFile(
|
|
182
|
+
target,
|
|
183
|
+
Buffer.from([
|
|
184
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
|
185
|
+
0x52,
|
|
186
|
+
]),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const r = await handleFileFetch({ path: target });
|
|
190
|
+
if (!r.ok) {
|
|
191
|
+
throw new Error("expected ok");
|
|
192
|
+
}
|
|
193
|
+
expect(r.mimeType).toBe("image/png");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("handleFileFetch — size enforcement", () => {
|
|
198
|
+
it("returns FILE_TOO_LARGE when stat size exceeds the cap", async () => {
|
|
199
|
+
const target = path.join(tmpRoot, "big.bin");
|
|
200
|
+
const data = Buffer.alloc(2048, 0xab);
|
|
201
|
+
await fs.writeFile(target, data);
|
|
202
|
+
|
|
203
|
+
const r = await handleFileFetch({ path: target, maxBytes: 1024 });
|
|
204
|
+
expectFailureCode(r, "FILE_TOO_LARGE");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("clamps maxBytes to the hard ceiling", async () => {
|
|
208
|
+
expect(FILE_FETCH_HARD_MAX_BYTES).toBe(16 * 1024 * 1024);
|
|
209
|
+
expect(FILE_FETCH_DEFAULT_MAX_BYTES).toBeLessThanOrEqual(FILE_FETCH_HARD_MAX_BYTES);
|
|
210
|
+
|
|
211
|
+
// A request asking for a maxBytes well above the hard ceiling should
|
|
212
|
+
// still be honored for a small file (no error).
|
|
213
|
+
const target = path.join(tmpRoot, "tiny.bin");
|
|
214
|
+
await fs.writeFile(target, Buffer.from([0x01, 0x02, 0x03]));
|
|
215
|
+
const r = await handleFileFetch({ path: target, maxBytes: Number.MAX_SAFE_INTEGER });
|
|
216
|
+
expect(r.ok).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("uses default cap when maxBytes is not finite or non-positive", async () => {
|
|
220
|
+
const target = path.join(tmpRoot, "small.bin");
|
|
221
|
+
await fs.writeFile(target, Buffer.from([0xff]));
|
|
222
|
+
expectSuccess(await handleFileFetch({ path: target, maxBytes: -1 }));
|
|
223
|
+
expectSuccess(await handleFileFetch({ path: target, maxBytes: Number.NaN }));
|
|
224
|
+
expectSuccess(await handleFileFetch({ path: target, maxBytes: "8" as unknown }));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("handleFileFetch — symlink handling", () => {
|
|
229
|
+
it("refuses to follow a symlink by default (SYMLINK_REDIRECT)", async () => {
|
|
230
|
+
const real = path.join(tmpRoot, "real.txt");
|
|
231
|
+
const link = path.join(tmpRoot, "link.txt");
|
|
232
|
+
await fs.writeFile(real, "data");
|
|
233
|
+
await fs.symlink(real, link);
|
|
234
|
+
|
|
235
|
+
const r = await handleFileFetch({ path: link });
|
|
236
|
+
expectFailureCode(r, "SYMLINK_REDIRECT");
|
|
237
|
+
// Caller learns the canonical target so the operator can update the
|
|
238
|
+
// allowlist or set followSymlinks=true.
|
|
239
|
+
expect(r.ok ? null : r.canonicalPath).toBe(real);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("follows symlinks and returns the canonical path when followSymlinks=true", async () => {
|
|
243
|
+
const real = path.join(tmpRoot, "real.txt");
|
|
244
|
+
const link = path.join(tmpRoot, "link.txt");
|
|
245
|
+
await fs.writeFile(real, "data");
|
|
246
|
+
await fs.symlink(real, link);
|
|
247
|
+
|
|
248
|
+
const r = await handleFileFetch({ path: link, followSymlinks: true });
|
|
249
|
+
if (!r.ok) {
|
|
250
|
+
throw new Error(`expected ok, got ${r.code}`);
|
|
251
|
+
}
|
|
252
|
+
expect(path.basename(r.path)).toBe("real.txt");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// File Transfer plugin module implements file fetch behavior.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { detectMime } from "actagent/plugin-sdk/media-mime";
|
|
5
|
+
import { root } from "actagent/plugin-sdk/security-runtime";
|
|
6
|
+
import {
|
|
7
|
+
classifyFsSafeReadError,
|
|
8
|
+
readAbsolutePath,
|
|
9
|
+
resolveCanonicalReadPath,
|
|
10
|
+
} from "./path-errors.js";
|
|
11
|
+
|
|
12
|
+
export const FILE_FETCH_HARD_MAX_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
export const FILE_FETCH_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
const TEXT_SNIFF_MAX_BYTES = 8192;
|
|
15
|
+
|
|
16
|
+
type FileFetchParams = {
|
|
17
|
+
path?: unknown;
|
|
18
|
+
maxBytes?: unknown;
|
|
19
|
+
followSymlinks?: unknown;
|
|
20
|
+
preflightOnly?: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FileFetchOk = {
|
|
24
|
+
ok: true;
|
|
25
|
+
path: string;
|
|
26
|
+
size: number;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
base64: string;
|
|
29
|
+
sha256: string;
|
|
30
|
+
preflightOnly?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type FileFetchErrCode =
|
|
34
|
+
| "INVALID_PATH"
|
|
35
|
+
| "NOT_FOUND"
|
|
36
|
+
| "PERMISSION_DENIED"
|
|
37
|
+
| "IS_DIRECTORY"
|
|
38
|
+
| "FILE_TOO_LARGE"
|
|
39
|
+
| "PATH_TRAVERSAL"
|
|
40
|
+
| "SYMLINK_REDIRECT"
|
|
41
|
+
| "READ_ERROR";
|
|
42
|
+
|
|
43
|
+
type FileFetchErr = {
|
|
44
|
+
ok: false;
|
|
45
|
+
code: FileFetchErrCode;
|
|
46
|
+
message: string;
|
|
47
|
+
canonicalPath?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type FileFetchResult = FileFetchOk | FileFetchErr;
|
|
51
|
+
|
|
52
|
+
function clampMaxBytes(input: unknown): number {
|
|
53
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input <= 0) {
|
|
54
|
+
return FILE_FETCH_DEFAULT_MAX_BYTES;
|
|
55
|
+
}
|
|
56
|
+
return Math.min(Math.floor(input), FILE_FETCH_HARD_MAX_BYTES);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classifyFsError(err: unknown): FileFetchErrCode {
|
|
60
|
+
const safeCode = classifyFsSafeReadError(err);
|
|
61
|
+
if (safeCode) {
|
|
62
|
+
return safeCode;
|
|
63
|
+
}
|
|
64
|
+
const code = (err as { code?: string } | null)?.code;
|
|
65
|
+
if (code === "not-file") {
|
|
66
|
+
return "IS_DIRECTORY";
|
|
67
|
+
}
|
|
68
|
+
if (code === "ENOENT") {
|
|
69
|
+
return "NOT_FOUND";
|
|
70
|
+
}
|
|
71
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
72
|
+
return "PERMISSION_DENIED";
|
|
73
|
+
}
|
|
74
|
+
if (code === "EISDIR") {
|
|
75
|
+
return "IS_DIRECTORY";
|
|
76
|
+
}
|
|
77
|
+
return "READ_ERROR";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isLikelyPlainText(buffer: Buffer): boolean {
|
|
81
|
+
if (buffer.byteLength === 0) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const sample = buffer.subarray(0, TEXT_SNIFF_MAX_BYTES);
|
|
85
|
+
if (sample.includes(0)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
new TextDecoder("utf-8", { fatal: true }).decode(sample);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
let controlBytes = 0;
|
|
94
|
+
for (const byte of sample) {
|
|
95
|
+
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
|
|
96
|
+
controlBytes += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return controlBytes / sample.byteLength < 0.01;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function detectFetchedFileMime(params: {
|
|
103
|
+
buffer: Buffer;
|
|
104
|
+
filePath: string;
|
|
105
|
+
}): Promise<string> {
|
|
106
|
+
const detected = await detectMime(params);
|
|
107
|
+
if (detected) {
|
|
108
|
+
return detected;
|
|
109
|
+
}
|
|
110
|
+
return isLikelyPlainText(params.buffer) ? "text/plain" : "application/octet-stream";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function handleFileFetch(params: FileFetchParams): Promise<FileFetchResult> {
|
|
114
|
+
const requestedPath = readAbsolutePath(params.path);
|
|
115
|
+
if (typeof requestedPath !== "string") {
|
|
116
|
+
return requestedPath;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const maxBytes = clampMaxBytes(params.maxBytes);
|
|
120
|
+
const followSymlinks = params.followSymlinks === true;
|
|
121
|
+
const preflightOnly = params.preflightOnly === true;
|
|
122
|
+
|
|
123
|
+
const canonical = await resolveCanonicalReadPath({
|
|
124
|
+
requestedPath,
|
|
125
|
+
followSymlinks,
|
|
126
|
+
classifyError: classifyFsError,
|
|
127
|
+
notFoundMessage: "file not found",
|
|
128
|
+
});
|
|
129
|
+
if (typeof canonical !== "string") {
|
|
130
|
+
return canonical;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let opened: Awaited<ReturnType<Awaited<ReturnType<typeof root>>["open"]>>;
|
|
134
|
+
try {
|
|
135
|
+
const parentRoot = await root(path.dirname(canonical));
|
|
136
|
+
opened = await parentRoot.open(path.basename(canonical));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const code = classifyFsError(err);
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
code,
|
|
142
|
+
message: code === "IS_DIRECTORY" ? "path is a directory" : `open failed: ${String(err)}`,
|
|
143
|
+
canonicalPath: canonical,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const stats = opened.stat;
|
|
149
|
+
if (stats.size > maxBytes) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
code: "FILE_TOO_LARGE",
|
|
153
|
+
message: `file size ${stats.size} exceeds limit ${maxBytes}`,
|
|
154
|
+
canonicalPath: opened.realPath,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (preflightOnly) {
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
path: opened.realPath,
|
|
162
|
+
size: stats.size,
|
|
163
|
+
mimeType: "",
|
|
164
|
+
base64: "",
|
|
165
|
+
sha256: "",
|
|
166
|
+
preflightOnly: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const buffer = await opened.handle.readFile();
|
|
171
|
+
if (buffer.byteLength > maxBytes) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
code: "FILE_TOO_LARGE",
|
|
175
|
+
message: `read ${buffer.byteLength} bytes exceeds limit ${maxBytes}`,
|
|
176
|
+
canonicalPath: opened.realPath,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
|
181
|
+
const base64 = buffer.toString("base64");
|
|
182
|
+
const mimeType = await detectFetchedFileMime({ buffer, filePath: opened.realPath });
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
path: opened.realPath,
|
|
187
|
+
size: buffer.byteLength,
|
|
188
|
+
mimeType,
|
|
189
|
+
base64,
|
|
190
|
+
sha256,
|
|
191
|
+
};
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const code = classifyFsError(err);
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
code,
|
|
197
|
+
message: `read failed: ${String(err)}`,
|
|
198
|
+
canonicalPath: opened.realPath,
|
|
199
|
+
};
|
|
200
|
+
} finally {
|
|
201
|
+
await opened.handle.close().catch(() => undefined);
|
|
202
|
+
}
|
|
203
|
+
}
|