diff --git a/src/server/workspaces/fileContentService.delete.test.ts b/src/server/workspaces/fileContentService.delete.test.ts
new file mode 100644
index 0000000..93c7fa4
--- /dev/null
+++ b/src/server/workspaces/fileContentService.delete.test.ts
@@ -0,0 +1,81 @@
+import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { deleteWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
+import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
+
+afterEach(async () => {
+ await cleanupTempWorkspaces();
+});
+
+describe("deleteWorkspaceFile", () => {
+ it("deletes an existing file and returns existed: true", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "notes.txt"), "hello");
+
+ const result = await deleteWorkspaceFile(root, "notes.txt");
+
+ expect(result).toMatchObject({ path: "notes.txt", existed: true });
+ await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
+ });
+
+ it("returns existed: false when deleting a non-existent file", async () => {
+ const root = await createTempWorkspace();
+
+ const result = await deleteWorkspaceFile(root, "missing.txt");
+
+ expect(result).toMatchObject({ path: "missing.txt", existed: false });
+ });
+
+ it("rejects deleting a directory", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "mydir"), { recursive: true });
+
+ await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
+ });
+
+ it("rejects traversal and absolute paths", async () => {
+ const root = await createTempWorkspace();
+
+ await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
+ await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
+ });
+
+ it("rejects missing path", async () => {
+ const root = await createTempWorkspace();
+
+ await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
+ await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
+ });
+
+ it("deletes a symlink itself, not its target", async () => {
+ const root = await createTempWorkspace();
+ const outsideDir = await createTempWorkspace("pi-web-outside-delete-");
+ await writeFile(join(outsideDir, "real.txt"), "real content");
+ // Create a symlink inside the workspace pointing outside
+ await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
+
+ const result = await deleteWorkspaceFile(root, "link.txt");
+
+ expect(result).toMatchObject({ path: "link.txt", existed: true });
+ // The symlink should be gone, but the target file should still exist
+ await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist");
+ const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
+ expect(realContent).toBe("real content");
+ });
+
+ it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "subdir"), { recursive: true });
+ // A real file living outside the workspace that must not be deletable.
+ const outsideDir = await createTempWorkspace("pi-web-outside-delete-parent-");
+ await writeFile(join(outsideDir, "victim.txt"), "important");
+ // A symlinked parent directory inside the workspace pointing outside.
+ await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
+
+ await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
+ // The outside file must survive.
+ const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
+ expect(realContent).toBe("important");
+ });
+});
diff --git a/src/server/workspaces/fileContentService.move.test.ts b/src/server/workspaces/fileContentService.move.test.ts
new file mode 100644
index 0000000..0d321f0
--- /dev/null
+++ b/src/server/workspaces/fileContentService.move.test.ts
@@ -0,0 +1,127 @@
+import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { moveWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
+import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
+
+afterEach(async () => {
+ await cleanupTempWorkspaces();
+});
+
+describe("moveWorkspaceFile", () => {
+ it("moves a file to a new path", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "original.txt"), "content");
+
+ const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
+
+ expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
+ expect(result.size).toBe(7);
+ expect(Date.parse(result.modifiedAt)).not.toBeNaN();
+ // Source should no longer exist
+ await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
+ // Target should exist
+ const target = await readWorkspaceFile(root, "moved.txt");
+ expect(target.content).toBe("content");
+ });
+
+ it("creates intermediate directories by default", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "file.txt"), "data");
+
+ await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
+
+ const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
+ expect(target.content).toBe("data");
+ });
+
+ it("fails when createDirs is false and parent directory does not exist", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "file.txt"), "data");
+
+ await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
+ const source = await readWorkspaceFile(root, "file.txt");
+ expect(source.content).toBe("data");
+ });
+
+ it("overwrites target when overwrite is true", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "source.txt"), "source content");
+ await writeFile(join(root, "target.txt"), "target content");
+
+ const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
+
+ expect(result.toPath).toBe("target.txt");
+ const target = await readWorkspaceFile(root, "target.txt");
+ expect(target.content).toBe("source content");
+ });
+
+ it("throws when target exists and overwrite is false (default)", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "source.txt"), "source");
+ await writeFile(join(root, "target.txt"), "target");
+
+ await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
+ // Source and target should remain unchanged
+ const source = await readWorkspaceFile(root, "source.txt");
+ expect(source.content).toBe("source");
+ const target = await readWorkspaceFile(root, "target.txt");
+ expect(target.content).toBe("target");
+ });
+
+ it("rejects source path traversal", async () => {
+ const root = await createTempWorkspace();
+
+ await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
+ });
+
+ it("rejects target path traversal", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "source.txt"), "data");
+
+ await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
+ const source = await readWorkspaceFile(root, "source.txt");
+ expect(source.content).toBe("data");
+ });
+
+ it("rejects moving a directory", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "mydir"), { recursive: true });
+
+ await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
+ });
+
+ it("rejects missing fromPath or toPath", async () => {
+ const root = await createTempWorkspace();
+
+ await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
+ await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
+ await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
+ await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
+ });
+
+ it("prevents moving through symlinks that escape the workspace", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "subdir"), { recursive: true });
+ await writeFile(join(root, "subdir", "file.txt"), "data");
+ // Create a symlink inside the workspace that points outside
+ const outsideDir = await createTempWorkspace("pi-web-move-outside-");
+ await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
+
+ await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace");
+ const source = await readWorkspaceFile(root, "subdir/file.txt");
+ expect(source.content).toBe("data");
+ await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("prevents moving a source symlink that escapes the workspace", async () => {
+ const root = await createTempWorkspace();
+ const outsideDir = await createTempWorkspace("pi-web-move-source-outside-");
+ await writeFile(join(outsideDir, "secret.txt"), "secret");
+ await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt"));
+
+ await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace");
+ await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist");
+ await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret");
+ });
+});
diff --git a/src/server/workspaces/fileContentService.read.test.ts b/src/server/workspaces/fileContentService.read.test.ts
new file mode 100644
index 0000000..3e9080b
--- /dev/null
+++ b/src/server/workspaces/fileContentService.read.test.ts
@@ -0,0 +1,107 @@
+import { mkdir, truncate, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
+import { readWorkspaceFile } from "./fileContentService.js";
+import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
+import { readWorkspaceImagePreview } from "./imagePreviewService.js";
+
+afterEach(async () => {
+ await cleanupTempWorkspaces();
+});
+
+describe("readWorkspaceFile", () => {
+ it("reads text files with normalized paths and language metadata", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "src"));
+ await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n");
+
+ const file = await readWorkspaceFile(root, "./src//main.ts");
+
+ expect(file).toMatchObject({
+ path: "src/main.ts",
+ language: "typescript",
+ encoding: "utf8",
+ content: "const answer = 42;\n",
+ truncated: false,
+ binary: false,
+ });
+ expect(file.size).toBe(19);
+ expect(Date.parse(file.modifiedAt)).not.toBeNaN();
+ });
+
+ it("rejects missing paths, directories, traversal, and absolute paths", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "dir"));
+
+ await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
+ await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file");
+ await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist");
+ await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
+ await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
+ });
+
+ it("reads allowed absolute files outside the workspace", async () => {
+ const root = await createTempWorkspace();
+ const external = await createTempWorkspace();
+ await writeFile(join(external, "README.md"), "external docs\n");
+
+ const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
+
+ expect(file).toMatchObject({
+ path: join(external, "README.md"),
+ language: "markdown",
+ content: "external docs\n",
+ truncated: false,
+ binary: false,
+ });
+ await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
+ });
+
+ it("detects binary files and omits binary content", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
+
+ const file = await readWorkspaceFile(root, "image.bin");
+
+ expect(file).toMatchObject({ content: "", binary: true, truncated: false });
+ expect(file.size).toBe(4);
+ });
+
+ it("marks supported images as previewable", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
+
+ const file = await readWorkspaceFile(root, "logo.PNG");
+
+ expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
+ expect(file.size).toBe(9);
+ });
+
+ it("opens image preview streams only for supported images within the preview size limit", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "diagram.svg"), "");
+ await writeFile(join(root, "note.txt"), "hello");
+ await writeFile(join(root, "huge.png"), "");
+ await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
+
+ const preview = await readWorkspaceImagePreview(root, "diagram.svg");
+ preview.stream.destroy();
+
+ expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
+ await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
+ await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
+ });
+
+ it("truncates large text files", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
+
+ const file = await readWorkspaceFile(root, "large.md");
+
+ expect(file.language).toBe("markdown");
+ expect(file.content).toHaveLength(512 * 1024);
+ expect(file.truncated).toBe(true);
+ expect(file.binary).toBe(false);
+ });
+});
diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts
deleted file mode 100644
index 580d1a6..0000000
--- a/src/server/workspaces/fileContentService.test.ts
+++ /dev/null
@@ -1,395 +0,0 @@
-import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
-import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js";
-import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js";
-import { readWorkspaceImagePreview } from "./imagePreviewService.js";
-
-const roots: string[] = [];
-
-async function tempWorkspace(): Promise {
- const root = await mkdtemp(join(tmpdir(), "pi-web-file-content-"));
- roots.push(root);
- return root;
-}
-
-afterEach(async () => {
- await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
-});
-
-describe("readWorkspaceFile", () => {
- it("reads text files with normalized paths and language metadata", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "src"));
- await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n");
-
- const file = await readWorkspaceFile(root, "./src//main.ts");
-
- expect(file).toMatchObject({
- path: "src/main.ts",
- language: "typescript",
- encoding: "utf8",
- content: "const answer = 42;\n",
- truncated: false,
- binary: false,
- });
- expect(file.size).toBe(19);
- expect(Date.parse(file.modifiedAt)).not.toBeNaN();
- });
-
- it("rejects missing paths, directories, traversal, and absolute paths", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "dir"));
-
- await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
- await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file");
- await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist");
- await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
- await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
- });
-
- it("reads allowed absolute files outside the workspace", async () => {
- const root = await tempWorkspace();
- const external = await tempWorkspace();
- await writeFile(join(external, "README.md"), "external docs\n");
-
- const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
-
- expect(file).toMatchObject({
- path: join(external, "README.md"),
- language: "markdown",
- content: "external docs\n",
- truncated: false,
- binary: false,
- });
- await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
- });
-
- it("detects binary files and omits binary content", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
-
- const file = await readWorkspaceFile(root, "image.bin");
-
- expect(file).toMatchObject({ content: "", binary: true, truncated: false });
- expect(file.size).toBe(4);
- });
-
- it("marks supported images as previewable", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
-
- const file = await readWorkspaceFile(root, "logo.PNG");
-
- expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
- expect(file.size).toBe(9);
- });
-
- it("opens image preview streams only for supported images within the preview size limit", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "diagram.svg"), "");
- await writeFile(join(root, "note.txt"), "hello");
- await writeFile(join(root, "huge.png"), "");
- await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
-
- const preview = await readWorkspaceImagePreview(root, "diagram.svg");
- preview.stream.destroy();
-
- expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
- await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
- await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
- });
-
- it("truncates large text files", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
-
- const file = await readWorkspaceFile(root, "large.md");
-
- expect(file.language).toBe("markdown");
- expect(file.content).toHaveLength(512 * 1024);
- expect(file.truncated).toBe(true);
- expect(file.binary).toBe(false);
- });
-});
-
-describe("writeWorkspaceFile", () => {
- it("writes text content to a new file with normalized paths", async () => {
- const root = await tempWorkspace();
-
- const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
-
- expect(result).toMatchObject({ path: "src/hello.ts", created: true });
- expect(result.size).toBe(26);
- expect(Date.parse(result.modifiedAt)).not.toBeNaN();
-
- // Verify the file was actually written
- const content = await readFile(join(root, "src", "hello.ts"), "utf8");
- expect(content).toBe("const greeting = 'hello';\n");
- });
-
- it("writes binary content without text re-encoding", async () => {
- const root = await tempWorkspace();
- const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
-
- const result = await writeWorkspaceFile(root, "image.png", binaryData);
-
- expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
- await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData);
- });
-
- it("overwrites existing files by default", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "notes.txt"), "old content");
-
- const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
-
- expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
- const content = await readFile(join(root, "notes.txt"), "utf8");
- expect(content).toBe("new content");
- });
-
- it("throws when overwrite is false and file exists", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "existing.txt"), "data");
-
- await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
- });
-
- it("creates intermediate directories by default", async () => {
- const root = await tempWorkspace();
-
- await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
-
- const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
- expect(content).toBe("deep content");
- });
-
- it("fails when createDirs is false and parent directory does not exist", async () => {
- const root = await tempWorkspace();
-
- await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
- });
-
- it("rejects missing paths, traversal, and absolute paths", async () => {
- const root = await tempWorkspace();
-
- await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
- await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
- await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
- });
-
- it("rejects writing to a directory path", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "mydir"), { recursive: true });
-
- await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
- });
-
- it("prevents writing through symlinks that escape the workspace", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "subdir"), { recursive: true });
- const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
- roots.push(outsideDir);
- await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
-
- await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace");
- await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" });
- });
-});
-
-describe("deleteWorkspaceFile", () => {
- it("deletes an existing file and returns existed: true", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "notes.txt"), "hello");
-
- const result = await deleteWorkspaceFile(root, "notes.txt");
-
- expect(result).toMatchObject({ path: "notes.txt", existed: true });
- await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
- });
-
- it("returns existed: false when deleting a non-existent file", async () => {
- const root = await tempWorkspace();
-
- const result = await deleteWorkspaceFile(root, "missing.txt");
-
- expect(result).toMatchObject({ path: "missing.txt", existed: false });
- });
-
- it("rejects deleting a directory", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "mydir"), { recursive: true });
-
- await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
- });
-
- it("rejects traversal and absolute paths", async () => {
- const root = await tempWorkspace();
-
- await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
- await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
- });
-
- it("rejects missing path", async () => {
- const root = await tempWorkspace();
-
- await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
- await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
- });
-
- it("deletes a symlink itself, not its target", async () => {
- const root = await tempWorkspace();
- const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-"));
- roots.push(outsideDir);
- await writeFile(join(outsideDir, "real.txt"), "real content");
- // Create a symlink inside the workspace pointing outside
- await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
-
- const result = await deleteWorkspaceFile(root, "link.txt");
-
- expect(result).toMatchObject({ path: "link.txt", existed: true });
- // The symlink should be gone, but the target file should still exist
- await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist");
- const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
- expect(realContent).toBe("real content");
- });
-
- it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "subdir"), { recursive: true });
- // A real file living outside the workspace that must not be deletable.
- const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
- roots.push(outsideDir);
- await writeFile(join(outsideDir, "victim.txt"), "important");
- // A symlinked parent directory inside the workspace pointing outside.
- await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
-
- await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
- // The outside file must survive.
- const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
- expect(realContent).toBe("important");
- });
-});
-
-describe("moveWorkspaceFile", () => {
- it("moves a file to a new path", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "original.txt"), "content");
-
- const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
-
- expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
- expect(result.size).toBe(7);
- expect(Date.parse(result.modifiedAt)).not.toBeNaN();
- // Source should no longer exist
- await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
- // Target should exist
- const target = await readWorkspaceFile(root, "moved.txt");
- expect(target.content).toBe("content");
- });
-
- it("creates intermediate directories by default", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "file.txt"), "data");
-
- await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
-
- const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
- expect(target.content).toBe("data");
- });
-
- it("fails when createDirs is false and parent directory does not exist", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "file.txt"), "data");
-
- await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
- const source = await readWorkspaceFile(root, "file.txt");
- expect(source.content).toBe("data");
- });
-
- it("overwrites target when overwrite is true", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "source.txt"), "source content");
- await writeFile(join(root, "target.txt"), "target content");
-
- const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
-
- expect(result.toPath).toBe("target.txt");
- const target = await readWorkspaceFile(root, "target.txt");
- expect(target.content).toBe("source content");
- });
-
- it("throws when target exists and overwrite is false (default)", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "source.txt"), "source");
- await writeFile(join(root, "target.txt"), "target");
-
- await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
- // Source and target should remain unchanged
- const source = await readWorkspaceFile(root, "source.txt");
- expect(source.content).toBe("source");
- const target = await readWorkspaceFile(root, "target.txt");
- expect(target.content).toBe("target");
- });
-
- it("rejects source path traversal", async () => {
- const root = await tempWorkspace();
-
- await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
- });
-
- it("rejects target path traversal", async () => {
- const root = await tempWorkspace();
- await writeFile(join(root, "source.txt"), "data");
-
- await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
- const source = await readWorkspaceFile(root, "source.txt");
- expect(source.content).toBe("data");
- });
-
- it("rejects moving a directory", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "mydir"), { recursive: true });
-
- await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
- });
-
- it("rejects missing fromPath or toPath", async () => {
- const root = await tempWorkspace();
-
- await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
- await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
- await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
- await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
- });
-
- it("prevents moving through symlinks that escape the workspace", async () => {
- const root = await tempWorkspace();
- await mkdir(join(root, "subdir"), { recursive: true });
- await writeFile(join(root, "subdir", "file.txt"), "data");
- // Create a symlink inside the workspace that points outside
- const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-"));
- roots.push(outsideDir);
- await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
-
- await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace");
- const source = await readWorkspaceFile(root, "subdir/file.txt");
- expect(source.content).toBe("data");
- await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
- });
-
- it("prevents moving a source symlink that escapes the workspace", async () => {
- const root = await tempWorkspace();
- const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-source-outside-"));
- roots.push(outsideDir);
- await writeFile(join(outsideDir, "secret.txt"), "secret");
- await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt"));
-
- await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace");
- await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist");
- await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret");
- });
-});
diff --git a/src/server/workspaces/fileContentService.testSupport.ts b/src/server/workspaces/fileContentService.testSupport.ts
new file mode 100644
index 0000000..125f966
--- /dev/null
+++ b/src/server/workspaces/fileContentService.testSupport.ts
@@ -0,0 +1,15 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const tempRoots: string[] = [];
+
+export async function createTempWorkspace(prefix = "pi-web-file-content-"): Promise {
+ const root = await mkdtemp(join(tmpdir(), prefix));
+ tempRoots.push(root);
+ return root;
+}
+
+export async function cleanupTempWorkspaces(): Promise {
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+}
diff --git a/src/server/workspaces/fileContentService.write.test.ts b/src/server/workspaces/fileContentService.write.test.ts
new file mode 100644
index 0000000..1f9c426
--- /dev/null
+++ b/src/server/workspaces/fileContentService.write.test.ts
@@ -0,0 +1,93 @@
+import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { writeWorkspaceFile } from "./fileContentService.js";
+import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
+
+afterEach(async () => {
+ await cleanupTempWorkspaces();
+});
+
+describe("writeWorkspaceFile", () => {
+ it("writes text content to a new file with normalized paths", async () => {
+ const root = await createTempWorkspace();
+
+ const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
+
+ expect(result).toMatchObject({ path: "src/hello.ts", created: true });
+ expect(result.size).toBe(26);
+ expect(Date.parse(result.modifiedAt)).not.toBeNaN();
+
+ // Verify the file was actually written
+ const content = await readFile(join(root, "src", "hello.ts"), "utf8");
+ expect(content).toBe("const greeting = 'hello';\n");
+ });
+
+ it("writes binary content without text re-encoding", async () => {
+ const root = await createTempWorkspace();
+ const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
+
+ const result = await writeWorkspaceFile(root, "image.png", binaryData);
+
+ expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
+ await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData);
+ });
+
+ it("overwrites existing files by default", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "notes.txt"), "old content");
+
+ const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
+
+ expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
+ const content = await readFile(join(root, "notes.txt"), "utf8");
+ expect(content).toBe("new content");
+ });
+
+ it("throws when overwrite is false and file exists", async () => {
+ const root = await createTempWorkspace();
+ await writeFile(join(root, "existing.txt"), "data");
+
+ await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
+ });
+
+ it("creates intermediate directories by default", async () => {
+ const root = await createTempWorkspace();
+
+ await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
+
+ const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
+ expect(content).toBe("deep content");
+ });
+
+ it("fails when createDirs is false and parent directory does not exist", async () => {
+ const root = await createTempWorkspace();
+
+ await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
+ });
+
+ it("rejects missing paths, traversal, and absolute paths", async () => {
+ const root = await createTempWorkspace();
+
+ await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
+ await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
+ await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
+ });
+
+ it("rejects writing to a directory path", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "mydir"), { recursive: true });
+
+ await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
+ });
+
+ it("prevents writing through symlinks that escape the workspace", async () => {
+ const root = await createTempWorkspace();
+ await mkdir(join(root, "subdir"), { recursive: true });
+ const outsideDir = await createTempWorkspace("pi-web-outside-");
+ await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
+
+ await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace");
+ await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" });
+ });
+});