From b9a995c9cbb16f01a005882688f4089be2c28134 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 10 Jul 2026 08:02:01 +0800 Subject: [PATCH] Fix Windows file delete command (#1005) --- .../file-manager-operation-commands.test.ts | 45 +++++ .../ssh/file-manager-operation-commands.ts | 39 ++++ .../ssh/file-manager-operation-routes.ts | 174 ++++++++---------- 3 files changed, 163 insertions(+), 95 deletions(-) create mode 100644 src/backend/ssh/file-manager-operation-commands.test.ts create mode 100644 src/backend/ssh/file-manager-operation-commands.ts diff --git a/src/backend/ssh/file-manager-operation-commands.test.ts b/src/backend/ssh/file-manager-operation-commands.test.ts new file mode 100644 index 00000000..986da0ba --- /dev/null +++ b/src/backend/ssh/file-manager-operation-commands.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { buildDeleteCommand } from "./file-manager-operation-commands.js"; + +describe("buildDeleteCommand", () => { + it("builds a PowerShell 5.1 compatible delete command for Windows files", () => { + const command = buildDeleteCommand( + "/C:/Users/Administrator/test.txt", + false, + ); + + expect(command.command).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop", + ); + expect(command.commandWithSuccess).toBe( + `${command.command}; if ($?) { Write-Output "SUCCESS" }`, + ); + expect(command.commandWithSuccess).not.toContain("&&"); + }); + + it("adds recursive deletion for Windows directories", () => { + const command = buildDeleteCommand("C:/Temp/Folder", true); + + expect(command.command).toBe( + "Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop", + ); + }); + + it("escapes single quotes in Windows literal paths", () => { + const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false); + + expect(command.command).toBe( + "Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop", + ); + }); + + it("keeps POSIX delete commands using shell success chaining", () => { + const command = buildDeleteCommand("/tmp/O'Brien.txt", false); + + expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'"); + expect(command.commandWithSuccess).toBe( + `${command.command} && echo "SUCCESS"`, + ); + }); +}); diff --git a/src/backend/ssh/file-manager-operation-commands.ts b/src/backend/ssh/file-manager-operation-commands.ts new file mode 100644 index 00000000..0eea91c9 --- /dev/null +++ b/src/backend/ssh/file-manager-operation-commands.ts @@ -0,0 +1,39 @@ +import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js"; + +export interface DeleteCommand { + command: string; + commandWithSuccess: string; +} + +function quotePosixPath(path: string): string { + return `'${path.replace(/'/g, "'\"'\"'")}'`; +} + +function quotePowerShellLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +export function buildDeleteCommand( + itemPath: string, + isDirectory: boolean, +): DeleteCommand { + if (isWindowsSftpPath(itemPath)) { + const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath)); + const command = isDirectory + ? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop` + : `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`; + + return { + command, + commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`, + }; + } + + const path = quotePosixPath(itemPath); + const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`; + + return { + command, + commandWithSuccess: `${command} && echo "SUCCESS"`, + }; +} diff --git a/src/backend/ssh/file-manager-operation-routes.ts b/src/backend/ssh/file-manager-operation-routes.ts index 3cbf25ee..317ceef2 100644 --- a/src/backend/ssh/file-manager-operation-routes.ts +++ b/src/backend/ssh/file-manager-operation-routes.ts @@ -6,7 +6,7 @@ import { execWithSudo, type SSHSession, } from "./file-manager-session.js"; -import { isWindowsSftpPath } from "./transfer-paths.js"; +import { buildDeleteCommand } from "./file-manager-operation-commands.js"; type FileOperationRoutesDeps = { sshSessions: Record; @@ -375,22 +375,10 @@ export function registerFileOperationRoutes( }); sshConn.lastActive = Date.now(); - const isWindowsPath = isWindowsSftpPath(itemPath); - let deleteCommand: string; - if (isWindowsPath) { - const winPath = itemPath - .replace(/\//g, "\\") - .replace(/^\\([A-Za-z]:\\)/, "$1"); - const escapedWinPath = winPath.replace(/"/g, '""'); - deleteCommand = isDirectory - ? `rd /s /q "${escapedWinPath}"` - : `del /f /q "${escapedWinPath}"`; - } else { - const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); - deleteCommand = isDirectory - ? `rm -rf '${escapedPath}'` - : `rm -f '${escapedPath}'`; - } + const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand( + itemPath, + Boolean(isDirectory), + ); const executeDelete = (useSudo: boolean): Promise => { return new Promise((resolve) => { @@ -421,89 +409,85 @@ export function registerFileOperationRoutes( return; } - execChannel( - sshConn, - `${deleteCommand} && echo "SUCCESS"`, - (err, stream) => { - if (err) { - fileLogger.error("SSH deleteItem error:", err); - res.status(500).json({ error: err.message }); + execChannel(sshConn, commandWithSuccess, (err, stream) => { + if (err) { + fileLogger.error("SSH deleteItem error:", err); + res.status(500).json({ error: err.message }); + resolve(); + return; + } + + let outputData = ""; + let errorData = ""; + let permissionDenied = false; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + if (chunk.toString().includes("Permission denied")) { + permissionDenied = true; + } + }); + + stream.on("close", (code) => { + if (permissionDenied) { + if (sshConn.sudoPassword) { + executeDelete(true).then(resolve); + return; + } + fileLogger.error(`Permission denied deleting: ${itemPath}`); + res.status(403).json({ + error: `Permission denied: Cannot delete ${itemPath}.`, + needsSudo: true, + }); resolve(); return; } - let outputData = ""; - let errorData = ""; - let permissionDenied = false; + if (outputData.includes("SUCCESS")) { + fileLogger.success("Item deleted successfully", { + operation: "file_delete_success", + sessionId, + userId, + path: itemPath, + }); + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } else { + const detail = + errorData.trim() || + outputData.trim() || + `command exited with code ${code} and produced no output (the remote shell may not support the delete command)`; + fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, { + operation: "file_delete_failed", + sessionId, + userId, + path: itemPath, + }); + res.status(500).json({ + error: `Delete failed: ${detail}`, + }); + } + resolve(); + }); - stream.on("data", (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - if (chunk.toString().includes("Permission denied")) { - permissionDenied = true; - } - }); - - stream.on("close", (code) => { - if (permissionDenied) { - if (sshConn.sudoPassword) { - executeDelete(true).then(resolve); - return; - } - fileLogger.error(`Permission denied deleting: ${itemPath}`); - res.status(403).json({ - error: `Permission denied: Cannot delete ${itemPath}.`, - needsSudo: true, - }); - resolve(); - return; - } - - if (outputData.includes("SUCCESS")) { - fileLogger.success("Item deleted successfully", { - operation: "file_delete_success", - sessionId, - userId, - path: itemPath, - }); - res.json({ - message: "Item deleted successfully", - path: itemPath, - toast: { - type: "success", - message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, - }, - }); - } else { - const detail = - errorData.trim() || - outputData.trim() || - `command exited with code ${code} and produced no output (the remote shell may not support the delete command)`; - fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, { - operation: "file_delete_failed", - sessionId, - userId, - path: itemPath, - }); - res.status(500).json({ - error: `Delete failed: ${detail}`, - }); - } - resolve(); - }); - - stream.on("error", (streamErr) => { - fileLogger.error("SSH deleteItem stream error:", streamErr); - res - .status(500) - .json({ error: `Stream error: ${streamErr.message}` }); - resolve(); - }); - }, - ); + stream.on("error", (streamErr) => { + fileLogger.error("SSH deleteItem stream error:", streamErr); + res + .status(500) + .json({ error: `Stream error: ${streamErr.message}` }); + resolve(); + }); + }); }); };