refactor(api): migrate workspace routes to resolveFilesystemPath
All workspace API routes use resolveFilesystemPath and isProtectedSystemPath, enabling browse-mode writes to external paths.
This commit is contained in:
parent
e490380d01
commit
3fe5a91033
@ -1,5 +1,5 @@
|
||||
import { readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { resolve, normalize } from "node:path";
|
||||
import { resolveFilesystemPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -44,8 +44,15 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize and resolve to prevent traversal
|
||||
const resolved = resolve(normalize(filePath));
|
||||
const resolvedPath = resolveFilesystemPath(filePath);
|
||||
if (!resolvedPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = resolvedPath.absolutePath;
|
||||
|
||||
if (!existsSync(resolved)) {
|
||||
return Response.json(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { cpSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, basename, extname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath } from "@/lib/workspace";
|
||||
import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -28,36 +28,50 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const srcAbs = safeResolvePath(relPath);
|
||||
if (!srcAbs) {
|
||||
const sourceTarget = resolveFilesystemPath(relPath);
|
||||
if (isProtectedSystemPath(sourceTarget)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot duplicate system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sourceTarget) {
|
||||
return Response.json(
|
||||
{ error: "Source not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
let destRelPath: string;
|
||||
let destinationInputPath: string;
|
||||
if (destinationPath && typeof destinationPath === "string") {
|
||||
destRelPath = destinationPath;
|
||||
destinationInputPath = destinationPath;
|
||||
} else {
|
||||
// Auto-generate "name copy.ext" or "name copy" for folders
|
||||
const name = basename(relPath);
|
||||
const dir = dirname(relPath);
|
||||
const name = basename(sourceTarget.absolutePath);
|
||||
const dir = dirname(sourceTarget.absolutePath);
|
||||
const ext = extname(name);
|
||||
const stem = ext ? name.slice(0, -ext.length) : name;
|
||||
const copyName = ext ? `${stem} copy${ext}` : `${stem} copy`;
|
||||
destRelPath = dir === "." ? copyName : `${dir}/${copyName}`;
|
||||
destinationInputPath = dir === "." ? copyName : `${dir}/${copyName}`;
|
||||
}
|
||||
|
||||
const destAbs = safeResolveNewPath(destRelPath);
|
||||
if (!destAbs) {
|
||||
const destinationTarget = resolveFilesystemPath(destinationInputPath, { allowMissing: true });
|
||||
if (!destinationTarget) {
|
||||
return Response.json(
|
||||
{ error: "Invalid destination path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(destAbs)) {
|
||||
if (isProtectedSystemPath(destinationTarget)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot duplicate to a protected system path" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(destinationTarget.absolutePath)) {
|
||||
return Response.json(
|
||||
{ error: "Destination already exists" },
|
||||
{ status: 409 },
|
||||
@ -65,9 +79,12 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const isDir = statSync(srcAbs).isDirectory();
|
||||
cpSync(srcAbs, destAbs, { recursive: isDir });
|
||||
return Response.json({ ok: true, sourcePath: relPath, newPath: destRelPath });
|
||||
const isDir = statSync(sourceTarget.absolutePath).isDirectory();
|
||||
cpSync(sourceTarget.absolutePath, destinationTarget.absolutePath, { recursive: isDir });
|
||||
const newPath = destinationTarget.workspaceRelativePath != null
|
||||
? destinationTarget.workspaceRelativePath
|
||||
: destinationTarget.absolutePath;
|
||||
return Response.json({ ok: true, sourcePath: relPath, newPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Copy failed" },
|
||||
|
||||
@ -18,8 +18,8 @@ vi.mock("node:fs", () => ({
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
readWorkspaceFile: vi.fn(),
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
}));
|
||||
|
||||
@ -41,8 +41,8 @@ describe("Workspace File Operations API", () => {
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
readWorkspaceFile: vi.fn(),
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
}));
|
||||
});
|
||||
@ -91,8 +91,13 @@ describe("Workspace File Operations API", () => {
|
||||
|
||||
describe("POST /api/workspace/file", () => {
|
||||
it("writes file content successfully", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/ws/doc.md",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "doc.md",
|
||||
});
|
||||
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
@ -109,9 +114,37 @@ describe("Workspace File Operations API", () => {
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes absolute browse-mode paths outside the workspace", async () => {
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/tmp/doc.md",
|
||||
kind: "absolute",
|
||||
withinWorkspace: false,
|
||||
workspaceRelativePath: null,
|
||||
});
|
||||
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "/tmp/doc.md", content: "# Hello from browse mode" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockMkdir).toHaveBeenCalled();
|
||||
expect(mockWrite).toHaveBeenCalledWith("/tmp/doc.md", "# Hello from browse mode", "utf-8");
|
||||
});
|
||||
|
||||
it("returns 403 when attempting to modify a system file", async () => {
|
||||
const { isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValueOnce(true);
|
||||
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/ws/IDENTITY.md",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "IDENTITY.md",
|
||||
});
|
||||
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
@ -146,8 +179,8 @@ describe("Workspace File Operations API", () => {
|
||||
});
|
||||
|
||||
it("returns 400 for path traversal", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue(null);
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
@ -171,8 +204,13 @@ describe("Workspace File Operations API", () => {
|
||||
});
|
||||
|
||||
it("returns 500 on write error", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/ws/doc.md",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "doc.md",
|
||||
});
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); });
|
||||
|
||||
@ -191,9 +229,13 @@ describe("Workspace File Operations API", () => {
|
||||
|
||||
describe("DELETE /api/workspace/file", () => {
|
||||
it("deletes file successfully", async () => {
|
||||
const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/file.txt");
|
||||
vi.mocked(isSystemFile).mockReturnValue(false);
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/ws/file.txt",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "file.txt",
|
||||
});
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
@ -208,8 +250,14 @@ describe("Workspace File Operations API", () => {
|
||||
});
|
||||
|
||||
it("returns 403 for system file", async () => {
|
||||
const { isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValue(true);
|
||||
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/ws/.object.yaml",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: ".object.yaml",
|
||||
});
|
||||
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
@ -222,8 +270,8 @@ describe("Workspace File Operations API", () => {
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValue(false);
|
||||
const { resolveFilesystemPath, safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue(null);
|
||||
vi.mocked(safeResolvePath).mockReturnValue(null);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
@ -263,8 +311,13 @@ describe("Workspace File Operations API", () => {
|
||||
|
||||
describe("POST /api/workspace/mkdir", () => {
|
||||
it("creates directory successfully", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/new-folder");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue({
|
||||
absolutePath: "/ws/new-folder",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "new-folder",
|
||||
});
|
||||
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
@ -290,8 +343,8 @@ describe("Workspace File Operations API", () => {
|
||||
});
|
||||
|
||||
it("returns 400 for traversal attempt", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue(null);
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { readWorkspaceFile, safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
import {
|
||||
readWorkspaceFile,
|
||||
safeResolvePath,
|
||||
resolveFilesystemPath,
|
||||
isProtectedSystemPath,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -49,16 +54,15 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(relPath)) {
|
||||
const targetPath = resolveFilesystemPath(relPath, { allowMissing: true });
|
||||
if (isProtectedSystemPath(targetPath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot modify system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Use safeResolveNewPath (not safeResolvePath) because the file may not exist yet
|
||||
const absPath = safeResolveNewPath(relPath);
|
||||
if (!absPath) {
|
||||
if (!targetPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
@ -66,8 +70,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
mkdirSync(dirname(targetPath.absolutePath), { recursive: true });
|
||||
writeFileSync(targetPath.absolutePath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
@ -100,14 +104,15 @@ export async function DELETE(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(relPath)) {
|
||||
const targetPath = resolveFilesystemPath(relPath);
|
||||
if (isProtectedSystemPath(targetPath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot delete system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
const absPath = targetPath?.absolutePath ?? safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { resolve, normalize } from "node:path";
|
||||
import { safeResolveNewPath } from "@/lib/workspace";
|
||||
import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -30,29 +30,25 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
let absPath: string | null;
|
||||
const targetPath = useAbsolute && !rawPath.startsWith("/") && !rawPath.startsWith("~/")
|
||||
? resolveFilesystemPath(resolve(normalize(rawPath)), { allowMissing: true })
|
||||
: resolveFilesystemPath(rawPath, { allowMissing: true });
|
||||
|
||||
if (useAbsolute) {
|
||||
const normalized = normalize(rawPath);
|
||||
if (normalized.includes("/../") || normalized.includes("/..")) {
|
||||
return Response.json(
|
||||
{ error: "Path traversal rejected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
absPath = resolve(normalized);
|
||||
} else {
|
||||
absPath = safeResolveNewPath(rawPath);
|
||||
}
|
||||
|
||||
if (!absPath) {
|
||||
if (!targetPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(absPath)) {
|
||||
if (isProtectedSystemPath(targetPath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot create a protected system path" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(targetPath.absolutePath)) {
|
||||
return Response.json(
|
||||
{ error: "Directory already exists" },
|
||||
{ status: 409 },
|
||||
@ -60,8 +56,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(absPath, { recursive: true });
|
||||
return Response.json({ ok: true, path: absPath });
|
||||
mkdirSync(targetPath.absolutePath, { recursive: true });
|
||||
return Response.json({
|
||||
ok: true,
|
||||
path: targetPath.workspaceRelativePath != null
|
||||
? targetPath.workspaceRelativePath
|
||||
: targetPath.absolutePath,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "mkdir failed" },
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { renameSync, existsSync, statSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
import { safeResolvePath, isSystemFile } from "@/lib/workspace";
|
||||
import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -28,23 +28,23 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(sourcePath)) {
|
||||
const sourceTarget = resolveFilesystemPath(sourcePath);
|
||||
if (isProtectedSystemPath(sourceTarget)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot move system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const srcAbs = safeResolvePath(sourcePath);
|
||||
if (!srcAbs) {
|
||||
if (!sourceTarget) {
|
||||
return Response.json(
|
||||
{ error: "Source not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const destDirAbs = safeResolvePath(destinationDir);
|
||||
if (!destDirAbs) {
|
||||
const destinationDirTarget = resolveFilesystemPath(destinationDir);
|
||||
if (!destinationDirTarget) {
|
||||
return Response.json(
|
||||
{ error: "Destination not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
@ -52,7 +52,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Destination must be a directory
|
||||
if (!statSync(destDirAbs).isDirectory()) {
|
||||
if (!statSync(destinationDirTarget.absolutePath).isDirectory()) {
|
||||
return Response.json(
|
||||
{ error: "Destination is not a directory" },
|
||||
{ status: 400 },
|
||||
@ -60,16 +60,31 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Prevent moving a folder into itself or its children
|
||||
const srcAbsNorm = srcAbs + "/";
|
||||
if (destDirAbs.startsWith(srcAbsNorm) || destDirAbs === srcAbs) {
|
||||
const srcAbsNorm = `${sourceTarget.absolutePath}/`;
|
||||
if (destinationDirTarget.absolutePath.startsWith(srcAbsNorm) || destinationDirTarget.absolutePath === sourceTarget.absolutePath) {
|
||||
return Response.json(
|
||||
{ error: "Cannot move a folder into itself" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const itemName = basename(srcAbs);
|
||||
const destAbs = join(destDirAbs, itemName);
|
||||
const itemName = basename(sourceTarget.absolutePath);
|
||||
const destAbs = join(destinationDirTarget.absolutePath, itemName);
|
||||
const destinationTarget = resolveFilesystemPath(destAbs, { allowMissing: true });
|
||||
|
||||
if (!destinationTarget) {
|
||||
return Response.json(
|
||||
{ error: "Invalid destination path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isProtectedSystemPath(destinationTarget)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot move a file to a protected system path" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(destAbs)) {
|
||||
return Response.json(
|
||||
@ -78,12 +93,12 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Build new relative path
|
||||
const newRelPath = destinationDir === "." ? itemName : `${destinationDir}/${itemName}`;
|
||||
|
||||
try {
|
||||
renameSync(srcAbs, destAbs);
|
||||
return Response.json({ ok: true, oldPath: sourcePath, newPath: newRelPath });
|
||||
renameSync(sourceTarget.absolutePath, destinationTarget.absolutePath);
|
||||
const newPath = destinationTarget.workspaceRelativePath != null
|
||||
? destinationTarget.workspaceRelativePath
|
||||
: destinationTarget.absolutePath;
|
||||
return Response.json({ ok: true, oldPath: sourcePath, newPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Move failed" },
|
||||
|
||||
@ -8,10 +8,9 @@ vi.mock("node:fs", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("POST /api/workspace/raw-file", () => {
|
||||
@ -24,10 +23,9 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
}));
|
||||
});
|
||||
|
||||
@ -46,8 +44,14 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("returns 403 when path is a system file (protects workspace.duckdb, etc.)", async () => {
|
||||
const { isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValueOnce(true);
|
||||
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/workspace.duckdb",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "workspace.duckdb",
|
||||
});
|
||||
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
|
||||
|
||||
const { POST } = await import("./raw-file/route.js");
|
||||
const req = new Request(
|
||||
@ -60,9 +64,9 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
expect(json.error).toContain("system file");
|
||||
});
|
||||
|
||||
it("returns 400 when safeResolveNewPath rejects the path (path traversal attack)", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce(null);
|
||||
it("returns 400 when path resolution rejects the path (path traversal attack)", async () => {
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null);
|
||||
|
||||
const { POST } = await import("./raw-file/route.js");
|
||||
const req = new Request(
|
||||
@ -76,8 +80,13 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("writes binary data to the resolved path and creates parent dirs", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/data/report.xlsx");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/data/report.xlsx",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "data/report.xlsx",
|
||||
});
|
||||
|
||||
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
|
||||
|
||||
@ -101,8 +110,13 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("returns 500 when writeFileSync throws (disk full, permission denied)", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/data.xlsx");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/data.xlsx",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "data.xlsx",
|
||||
});
|
||||
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockWrite).mockImplementationOnce(() => {
|
||||
@ -121,8 +135,13 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("preserves binary content exactly as received (no encoding corruption)", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/file.xlsx");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/file.xlsx",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "file.xlsx",
|
||||
});
|
||||
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockWrite).mockClear();
|
||||
@ -143,21 +162,25 @@ describe("POST /api/workspace/raw-file", () => {
|
||||
expect(writtenBuffer[4]).toBe(0x01);
|
||||
});
|
||||
|
||||
it("calls isSystemFile before safeResolveNewPath (rejects early, prevents resolve overhead)", async () => {
|
||||
const { isSystemFile, safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockClear();
|
||||
vi.mocked(safeResolveNewPath).mockClear();
|
||||
vi.mocked(isSystemFile).mockReturnValueOnce(true);
|
||||
it("writes external absolute .object.yaml files when they are outside the managed workspace", async () => {
|
||||
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/tmp/.object.yaml",
|
||||
kind: "absolute",
|
||||
withinWorkspace: false,
|
||||
workspaceRelativePath: null,
|
||||
});
|
||||
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(false);
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./raw-file/route.js");
|
||||
const req = new Request(
|
||||
"http://localhost/api/workspace/raw-file?path=workspace.duckdb",
|
||||
"http://localhost/api/workspace/raw-file?path=/tmp/.object.yaml",
|
||||
{ method: "POST", body: new ArrayBuffer(1) },
|
||||
);
|
||||
await POST(req);
|
||||
|
||||
expect(isSystemFile).toHaveBeenCalledWith("workspace.duckdb");
|
||||
expect(safeResolveNewPath).not.toHaveBeenCalled();
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWrite).toHaveBeenCalledWith("/tmp/.object.yaml", expect.any(Buffer));
|
||||
});
|
||||
});
|
||||
|
||||
@ -171,10 +194,9 @@ describe("GET /api/workspace/raw-file", () => {
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
}));
|
||||
});
|
||||
|
||||
@ -183,8 +205,13 @@ describe("GET /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("returns DOCX MIME type for .docx files (required for browser/editor interoperability)", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/docs/spec.docx");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/docs/spec.docx",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "docs/spec.docx",
|
||||
});
|
||||
const { readFileSync } = await import("node:fs");
|
||||
vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from([0x50, 0x4b]));
|
||||
|
||||
@ -199,8 +226,13 @@ describe("GET /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("returns text/plain for .txt files (ensures plain-text previews render correctly)", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/notes/today.txt");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/notes/today.txt",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "notes/today.txt",
|
||||
});
|
||||
const { readFileSync } = await import("node:fs");
|
||||
vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from("hello"));
|
||||
|
||||
@ -213,8 +245,13 @@ describe("GET /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for unknown extensions (prevents incorrect sniffing assumptions)", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValueOnce("/ws/blob.unknown");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/blob.unknown",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "blob.unknown",
|
||||
});
|
||||
const { readFileSync } = await import("node:fs");
|
||||
vi.mocked(readFileSync).mockReturnValueOnce(Buffer.from([1, 2, 3]));
|
||||
|
||||
@ -233,8 +270,8 @@ describe("GET /api/workspace/raw-file", () => {
|
||||
});
|
||||
|
||||
it("returns 404 when file cannot be resolved (prevents leaking host paths)", async () => {
|
||||
const { safeResolvePath, resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValueOnce(null);
|
||||
const { resolveFilesystemPath, resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null);
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValueOnce(null);
|
||||
|
||||
const { GET } = await import("./raw-file/route.js");
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath, resolveWorkspaceRoot, isSystemFile } from "@/lib/workspace";
|
||||
import {
|
||||
resolveFilesystemPath,
|
||||
resolveWorkspaceRoot,
|
||||
isProtectedSystemPath,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -50,18 +54,10 @@ const MIME_MAP: Record<string, string> = {
|
||||
* Security note: this is a local-only dev server; it never runs in production.
|
||||
*/
|
||||
function resolveFile(path: string): string | null {
|
||||
// 1. Absolute path — serve directly if it exists on disk
|
||||
if (path.startsWith("/")) {
|
||||
const abs = resolve(path);
|
||||
if (existsSync(abs)) {return abs;}
|
||||
// Fall through to workspace-relative in case the leading / is accidental
|
||||
}
|
||||
const resolvedPath = resolveFilesystemPath(path);
|
||||
if (resolvedPath) {return resolvedPath.absolutePath;}
|
||||
|
||||
// 2. Standard workspace-relative resolution
|
||||
const resolved = safeResolvePath(path);
|
||||
if (resolved) {return resolved;}
|
||||
|
||||
// 3. Try common subdirectories in case the path is a bare filename
|
||||
// 2. Try common subdirectories in case the path is a bare filename
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {return null;}
|
||||
const rootAbs = resolve(root);
|
||||
@ -139,12 +135,12 @@ export async function POST(req: Request) {
|
||||
return new Response("Missing path", { status: 400 });
|
||||
}
|
||||
|
||||
if (isSystemFile(path)) {
|
||||
const targetPath = resolveFilesystemPath(path, { allowMissing: true });
|
||||
if (isProtectedSystemPath(targetPath)) {
|
||||
return Response.json({ error: "Cannot modify system file" }, { status: 403 });
|
||||
}
|
||||
|
||||
const absPath = safeResolveNewPath(path);
|
||||
if (!absPath) {
|
||||
if (!targetPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
@ -153,8 +149,8 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await req.arrayBuffer());
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, buffer);
|
||||
mkdirSync(dirname(targetPath.absolutePath), { recursive: true });
|
||||
writeFileSync(targetPath.absolutePath, buffer);
|
||||
return Response.json({ ok: true, path });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { renameSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -28,7 +28,8 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(relPath)) {
|
||||
const sourcePath = resolveFilesystemPath(relPath);
|
||||
if (isProtectedSystemPath(sourcePath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot rename system file" },
|
||||
{ status: 403 },
|
||||
@ -43,28 +44,31 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
if (!sourcePath) {
|
||||
return Response.json(
|
||||
{ error: "Source not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const parentDir = dirname(absPath);
|
||||
const parentDir = dirname(sourcePath.absolutePath);
|
||||
const newAbsPath = join(parentDir, newName);
|
||||
const destinationPath = resolveFilesystemPath(newAbsPath, { allowMissing: true });
|
||||
|
||||
// Ensure the new path stays within workspace
|
||||
const parentRel = dirname(relPath);
|
||||
const newRelPath = parentRel === "." ? newName : `${parentRel}/${newName}`;
|
||||
const validated = safeResolveNewPath(newRelPath);
|
||||
if (!validated) {
|
||||
if (!destinationPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid destination path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isProtectedSystemPath(destinationPath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot rename to a protected system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(newAbsPath)) {
|
||||
return Response.json(
|
||||
{ error: `A file named '${newName}' already exists` },
|
||||
@ -73,8 +77,11 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(absPath, newAbsPath);
|
||||
return Response.json({ ok: true, oldPath: relPath, newPath: newRelPath });
|
||||
renameSync(sourcePath.absolutePath, destinationPath.absolutePath);
|
||||
const newPath = destinationPath.workspaceRelativePath != null
|
||||
? destinationPath.workspaceRelativePath
|
||||
: destinationPath.absolutePath;
|
||||
return Response.json({ ok: true, oldPath: relPath, newPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Rename failed" },
|
||||
|
||||
@ -6,8 +6,8 @@ vi.mock("node:fs", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("POST /api/workspace/write-binary", () => {
|
||||
@ -18,8 +18,8 @@ describe("POST /api/workspace/write-binary", () => {
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveFilesystemPath: vi.fn(),
|
||||
isProtectedSystemPath: vi.fn(() => false),
|
||||
}));
|
||||
});
|
||||
|
||||
@ -67,8 +67,14 @@ describe("POST /api/workspace/write-binary", () => {
|
||||
});
|
||||
|
||||
it("returns 403 when writing a system file (prevents protected-file tampering)", async () => {
|
||||
const { isSystemFile, safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValueOnce(true);
|
||||
const { resolveFilesystemPath, isProtectedSystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/workspace.duckdb",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "workspace.duckdb",
|
||||
});
|
||||
vi.mocked(isProtectedSystemPath).mockReturnValueOnce(true);
|
||||
|
||||
const { POST } = await import("./write-binary/route.js");
|
||||
const form = new FormData();
|
||||
@ -80,12 +86,11 @@ describe("POST /api/workspace/write-binary", () => {
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
expect(safeResolveNewPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when safe path resolution fails (blocks path traversal attacks)", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce(null);
|
||||
it("returns 400 when path resolution fails (blocks path traversal attacks)", async () => {
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce(null);
|
||||
|
||||
const { POST } = await import("./write-binary/route.js");
|
||||
const form = new FormData();
|
||||
@ -102,8 +107,13 @@ describe("POST /api/workspace/write-binary", () => {
|
||||
});
|
||||
|
||||
it("writes binary bytes exactly to the resolved destination", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/docs/report.docx");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/docs/report.docx",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "docs/report.docx",
|
||||
});
|
||||
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./write-binary/route.js");
|
||||
@ -131,9 +141,38 @@ describe("POST /api/workspace/write-binary", () => {
|
||||
expect(written[4]).toBe(0xff);
|
||||
});
|
||||
|
||||
it("writes absolute browse-mode DOCX paths outside the workspace", async () => {
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/tmp/report.docx",
|
||||
kind: "absolute",
|
||||
withinWorkspace: false,
|
||||
workspaceRelativePath: null,
|
||||
});
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./write-binary/route.js");
|
||||
const form = new FormData();
|
||||
form.append("path", "/tmp/report.docx");
|
||||
form.append("file", new Blob([new Uint8Array([1, 2, 3])]));
|
||||
const req = new Request("http://localhost/api/workspace/write-binary", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const res = await POST(req);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockWrite).toHaveBeenCalledWith("/tmp/report.docx", expect.any(Buffer));
|
||||
});
|
||||
|
||||
it("returns 500 when disk write throws (surfaces actionable failure)", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValueOnce("/ws/docs/report.docx");
|
||||
const { resolveFilesystemPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveFilesystemPath).mockReturnValueOnce({
|
||||
absolutePath: "/ws/docs/report.docx",
|
||||
kind: "workspaceRelative",
|
||||
withinWorkspace: true,
|
||||
workspaceRelativePath: "docs/report.docx",
|
||||
});
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockWrite).mockImplementationOnce(() => {
|
||||
throw new Error("ENOSPC: no space left on device");
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
import { resolveFilesystemPath, isProtectedSystemPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -28,12 +28,12 @@ export async function POST(req: Request) {
|
||||
return Response.json({ error: "Missing 'file' field (Blob)" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isSystemFile(relPath)) {
|
||||
const targetPath = resolveFilesystemPath(relPath, { allowMissing: true });
|
||||
if (isProtectedSystemPath(targetPath)) {
|
||||
return Response.json({ error: "Cannot modify system file" }, { status: 403 });
|
||||
}
|
||||
|
||||
const absPath = safeResolveNewPath(relPath);
|
||||
if (!absPath) {
|
||||
if (!targetPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
@ -42,8 +42,8 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, buffer);
|
||||
mkdirSync(dirname(targetPath.absolutePath), { recursive: true });
|
||||
writeFileSync(targetPath.absolutePath, buffer);
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user