Skip to content

Commit bfbaa3f

Browse files
NathanFlurryclaude
andcommitted
fix: harden VFS drivers and add comprehensive tests
Implementation fixes: - rename() now moves directory children (both S3 and SQLite) - SQLite: escape LIKE wildcards in readDir queries (% and _ injection) - SQLite: resolve symlinks in readFile/writeFile/exists - SQLite: guard against removeDir("/") - S3: removeDir throws ENOENT for nonexistent dirs - S3: createDir checks parent exists - S3: exists()/stat() only catch 404, not all errors - S3: pin MinIO Docker image version Test coverage expanded from 6/21 to 17/21 VFS methods: - S3: 12 tests (write, read, stat, exists, rename, rename-dir, remove, rmdir, truncate, err-symlink, err-read, err-rmdir) - SQLite: 15 tests (write, read, stat, exists, rename, rename-dir, remove, rmdir, chmod, truncate, symlink, link, err-read, err-rmdir, snapshot) - Both exit non-zero on failure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 805d752 commit bfbaa3f

6 files changed

Lines changed: 697 additions & 120 deletions

File tree

examples/virtual-file-system-s3/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
minio:
3-
image: minio/minio
3+
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
44
ports:
55
- "9000:9000"
66
- "9001:9001"

examples/virtual-file-system-s3/src/index.ts

Lines changed: 235 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { S3FileSystem } from "./s3-filesystem.js";
1515

1616
const BUCKET = "secure-exec-vfs-test";
1717

18-
// Connect to MinIO (S3-compatible) running on localhost
1918
const client = new S3Client({
2019
endpoint: "http://localhost:9000",
2120
region: "us-east-1",
@@ -26,7 +25,6 @@ const client = new S3Client({
2625
forcePathStyle: true,
2726
});
2827

29-
// Create test bucket
3028
try {
3129
await client.send(new CreateBucketCommand({ Bucket: BUCKET }));
3230
} catch (err: unknown) {
@@ -49,72 +47,257 @@ const runtime = new NodeRuntime({
4947
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
5048
});
5149

52-
try {
53-
// Sandbox writes files — stored in MinIO via S3 API
54-
const writeResult = await runtime.exec(`
55-
const fs = require("node:fs");
56-
fs.mkdirSync("/workspace", { recursive: true });
57-
fs.writeFileSync("/workspace/hello.txt", "hello from sandbox via S3");
58-
fs.writeFileSync("/workspace/data.json", JSON.stringify({ count: 42 }));
59-
console.log("files written");
60-
`);
61-
62-
if (writeResult.code !== 0) {
63-
throw new Error(`Write step failed: ${writeResult.errorMessage}`);
50+
const failures: string[] = [];
51+
52+
function assert(condition: boolean, message: string) {
53+
if (!condition) {
54+
failures.push(message);
55+
console.error(`FAIL: ${message}`);
6456
}
57+
}
6558

66-
// Sandbox reads files back
67-
let readOutput = "";
68-
const readResult = await runtime.exec(
69-
`
70-
const fs = require("node:fs");
71-
const text = fs.readFileSync("/workspace/hello.txt", "utf8");
72-
const data = JSON.parse(fs.readFileSync("/workspace/data.json", "utf8"));
73-
const entries = fs.readdirSync("/workspace");
74-
console.log(JSON.stringify({ text, count: data.count, entries }));
75-
`,
76-
{
77-
onStdio: (event) => {
78-
if (event.channel === "stdout") readOutput += event.message;
79-
},
59+
async function exec(
60+
code: string,
61+
): Promise<{ code: number; stdout: string; stderr: string }> {
62+
let stdout = "";
63+
let stderr = "";
64+
const result = await runtime.exec(code, {
65+
onStdio: (event) => {
66+
if (event.channel === "stdout") stdout += event.message;
67+
else stderr += event.message;
8068
},
81-
);
69+
});
70+
return { code: result.code, stdout, stderr };
71+
}
72+
73+
try {
74+
// --- writeFile + readFile + readDir ---
75+
{
76+
const r = await exec(`
77+
const fs = require("node:fs");
78+
fs.mkdirSync("/workspace", { recursive: true });
79+
fs.writeFileSync("/workspace/hello.txt", "hello from sandbox via S3");
80+
fs.writeFileSync("/workspace/data.json", JSON.stringify({ count: 42 }));
81+
const text = fs.readFileSync("/workspace/hello.txt", "utf8");
82+
const data = JSON.parse(fs.readFileSync("/workspace/data.json", "utf8"));
83+
const entries = fs.readdirSync("/workspace");
84+
console.log(JSON.stringify({ text, count: data.count, entries }));
85+
`);
86+
assert(r.code === 0, `writeFile+readFile exec failed: ${r.stderr}`);
87+
const parsed = JSON.parse(r.stdout.trim());
88+
assert(
89+
parsed.text === "hello from sandbox via S3",
90+
"readFile content mismatch",
91+
);
92+
assert(parsed.count === 42, "readFile JSON data mismatch");
93+
assert(
94+
parsed.entries.includes("hello.txt"),
95+
"readDir missing hello.txt",
96+
);
97+
assert(
98+
parsed.entries.includes("data.json"),
99+
"readDir missing data.json",
100+
);
101+
const hostContent = await filesystem.readTextFile(
102+
"/workspace/hello.txt",
103+
);
104+
assert(
105+
hostContent === "hello from sandbox via S3",
106+
"host readTextFile mismatch",
107+
);
108+
}
109+
110+
// --- stat ---
111+
{
112+
const r = await exec(`
113+
const fs = require("node:fs");
114+
const fileStat = fs.statSync("/workspace/hello.txt");
115+
const dirStat = fs.statSync("/workspace");
116+
console.log(JSON.stringify({
117+
fileIsDir: fileStat.isDirectory(),
118+
fileSize: fileStat.size,
119+
dirIsDir: dirStat.isDirectory(),
120+
}));
121+
`);
122+
assert(r.code === 0, `stat exec failed: ${r.stderr}`);
123+
const parsed = JSON.parse(r.stdout.trim());
124+
assert(!parsed.fileIsDir, "stat: file should not be directory");
125+
assert(parsed.fileSize > 0, "stat: file size should be > 0");
126+
assert(parsed.dirIsDir, "stat: dir should be directory");
127+
}
82128

83-
if (readResult.code !== 0) {
84-
throw new Error(`Read step failed: ${readResult.errorMessage}`);
129+
// --- exists ---
130+
{
131+
const r = await exec(`
132+
const fs = require("node:fs");
133+
console.log(JSON.stringify({
134+
yes: fs.existsSync("/workspace/hello.txt"),
135+
no: fs.existsSync("/nonexistent-file.xyz"),
136+
}));
137+
`);
138+
assert(r.code === 0, `exists exec failed: ${r.stderr}`);
139+
const parsed = JSON.parse(r.stdout.trim());
140+
assert(parsed.yes === true, "existsSync true for existing file");
141+
assert(parsed.no === false, "existsSync false for missing file");
85142
}
86143

87-
// Verify from host side via S3 API
88-
const hostContent = await filesystem.readTextFile("/workspace/hello.txt");
89-
const parsed = JSON.parse(readOutput.trim());
144+
// --- rename file ---
145+
{
146+
const r = await exec(`
147+
const fs = require("node:fs");
148+
fs.writeFileSync("/workspace/old.txt", "rename-me");
149+
fs.renameSync("/workspace/old.txt", "/workspace/new.txt");
150+
const content = fs.readFileSync("/workspace/new.txt", "utf8");
151+
const oldExists = fs.existsSync("/workspace/old.txt");
152+
console.log(JSON.stringify({ content, oldExists }));
153+
`);
154+
assert(r.code === 0, `rename exec failed: ${r.stderr}`);
155+
const parsed = JSON.parse(r.stdout.trim());
156+
assert(parsed.content === "rename-me", "rename: content mismatch");
157+
assert(!parsed.oldExists, "rename: old path should not exist");
158+
}
90159

91-
const ok =
92-
hostContent === "hello from sandbox via S3" &&
93-
parsed.text === "hello from sandbox via S3" &&
94-
parsed.count === 42 &&
95-
parsed.entries.includes("hello.txt") &&
96-
parsed.entries.includes("data.json");
160+
// --- rename directory with children ---
161+
{
162+
const r = await exec(`
163+
const fs = require("node:fs");
164+
fs.mkdirSync("/workspace/dirA/sub", { recursive: true });
165+
fs.writeFileSync("/workspace/dirA/sub/file.txt", "child-content");
166+
fs.renameSync("/workspace/dirA", "/workspace/dirB");
167+
const content = fs.readFileSync("/workspace/dirB/sub/file.txt", "utf8");
168+
const oldExists = fs.existsSync("/workspace/dirA");
169+
console.log(JSON.stringify({ content, oldExists }));
170+
`);
171+
assert(r.code === 0, `rename dir exec failed: ${r.stderr}`);
172+
const parsed = JSON.parse(r.stdout.trim());
173+
assert(
174+
parsed.content === "child-content",
175+
"rename dir: child content lost",
176+
);
177+
assert(!parsed.oldExists, "rename dir: old path should not exist");
178+
}
97179

180+
// --- removeFile ---
181+
{
182+
const r = await exec(`
183+
const fs = require("node:fs");
184+
fs.writeFileSync("/workspace/delete-me.txt", "gone");
185+
fs.unlinkSync("/workspace/delete-me.txt");
186+
console.log(JSON.stringify({ exists: fs.existsSync("/workspace/delete-me.txt") }));
187+
`);
188+
assert(r.code === 0, `removeFile exec failed: ${r.stderr}`);
189+
assert(
190+
JSON.parse(r.stdout.trim()).exists === false,
191+
"removeFile: file should be gone",
192+
);
193+
}
194+
195+
// --- removeDir ---
196+
{
197+
const r = await exec(`
198+
const fs = require("node:fs");
199+
fs.mkdirSync("/workspace/empty-dir");
200+
fs.rmdirSync("/workspace/empty-dir");
201+
console.log(JSON.stringify({ exists: fs.existsSync("/workspace/empty-dir") }));
202+
`);
203+
assert(r.code === 0, `removeDir exec failed: ${r.stderr}`);
204+
assert(
205+
JSON.parse(r.stdout.trim()).exists === false,
206+
"removeDir: dir should be gone",
207+
);
208+
}
209+
210+
// --- truncate ---
211+
{
212+
const r = await exec(`
213+
const fs = require("node:fs");
214+
fs.writeFileSync("/workspace/trunc.txt", "hello world");
215+
fs.truncateSync("/workspace/trunc.txt", 5);
216+
const content = fs.readFileSync("/workspace/trunc.txt", "utf8");
217+
console.log(JSON.stringify({ content }));
218+
`);
219+
assert(r.code === 0, `truncate exec failed: ${r.stderr}`);
220+
assert(
221+
JSON.parse(r.stdout.trim()).content === "hello",
222+
"truncate: should be 'hello'",
223+
);
224+
}
225+
226+
// --- error: symlink (should throw ENOSYS) ---
227+
{
228+
const r = await exec(`
229+
const fs = require("node:fs");
230+
try { fs.symlinkSync("/workspace/hello.txt", "/workspace/link"); console.log("NO_ERROR"); }
231+
catch (e) { console.log(e.message.includes("ENOSYS") ? "ENOSYS" : e.message); }
232+
`);
233+
assert(r.code === 0, `symlink error exec failed: ${r.stderr}`);
234+
assert(
235+
r.stdout.trim() === "ENOSYS",
236+
"symlink should throw ENOSYS on S3",
237+
);
238+
}
239+
240+
// --- error: read nonexistent ---
241+
{
242+
const r = await exec(`
243+
const fs = require("node:fs");
244+
try { fs.readFileSync("/nonexistent"); console.log("NO_ERROR"); }
245+
catch (e) { console.log(e.message.includes("ENOENT") ? "ENOENT" : e.message); }
246+
`);
247+
assert(r.code === 0, `error read exec failed: ${r.stderr}`);
248+
assert(
249+
r.stdout.trim() === "ENOENT",
250+
"read missing file should throw ENOENT",
251+
);
252+
}
253+
254+
// --- error: rmdir non-empty ---
255+
{
256+
const r = await exec(`
257+
const fs = require("node:fs");
258+
try { fs.rmdirSync("/workspace"); console.log("NO_ERROR"); }
259+
catch (e) { console.log(e.message.includes("ENOTEMPTY") || e.message.includes("not empty") ? "ENOTEMPTY" : e.message); }
260+
`);
261+
assert(r.code === 0, `error rmdir exec failed: ${r.stderr}`);
262+
assert(
263+
r.stdout.trim() === "ENOTEMPTY",
264+
"rmdir non-empty should throw ENOTEMPTY",
265+
);
266+
}
267+
268+
// --- result ---
269+
const ok = failures.length === 0;
98270
console.log(
99271
JSON.stringify({
100272
ok,
101-
hostContent,
102-
sandboxResult: parsed,
103-
summary:
104-
"S3-backed VFS: sandbox wrote files to MinIO, read them back, host verified via S3 API",
273+
passed: 12 - failures.length,
274+
total: 12,
275+
failures,
276+
summary: ok
277+
? "S3 VFS: 12/12 tests passed (write, read, stat, exists, rename, rename-dir, remove, rmdir, truncate, err-symlink, err-read, err-rmdir)"
278+
: `${failures.length} of 12 tests failed`,
105279
}),
106280
);
281+
282+
if (!ok) process.exit(1);
107283
} finally {
108284
runtime.dispose();
109285

110-
// Clean up: delete all objects and the bucket
111-
const list = await client.send(
112-
new ListObjectsV2Command({ Bucket: BUCKET }),
113-
);
114-
for (const obj of list.Contents ?? []) {
115-
await client.send(
116-
new DeleteObjectCommand({ Bucket: BUCKET, Key: obj.Key! }),
286+
// Clean up: paginated delete of all objects, then bucket
287+
let token: string | undefined;
288+
do {
289+
const list = await client.send(
290+
new ListObjectsV2Command({
291+
Bucket: BUCKET,
292+
ContinuationToken: token,
293+
}),
117294
);
118-
}
295+
for (const obj of list.Contents ?? []) {
296+
await client.send(
297+
new DeleteObjectCommand({ Bucket: BUCKET, Key: obj.Key! }),
298+
);
299+
}
300+
token = list.NextContinuationToken;
301+
} while (token);
119302
await client.send(new DeleteBucketCommand({ Bucket: BUCKET }));
120303
}

0 commit comments

Comments
 (0)