fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

This commit is contained in:
LukeGus committed 2026-09-30 12:12:41 -05:00
1 parent f896e121e2
commit e3eada899c
3 files changed
+87 -38

No files matched your search

@@ -31,6 +31,21 @@ const parseByteOffset = (value: string | undefined): number | null => {
return parsed;
};
/**
* The editor sends text. Binary callers say so with encoding "base64";
* guessing breaks text that happens to be valid base64.
*/
export function decodeWriteContent(
content: unknown,
encoding?: unknown,
): Buffer {
if (Buffer.isBuffer(content)) return content;
if (typeof content === "string") {
return Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
}
return Buffer.from(content as ArrayLike<number>);
}
export function registerFileContentRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileContentRoutesDeps,
@@ -435,16 +450,22 @@ export function registerFileContentRoutes(
* type: string
* content:
* type: string
* encoding:
* type: string
* enum: [utf8, base64]
* description: How content is encoded. Defaults to utf8.
* responses:
* 200:
* description: File written successfully.
* 403:
* description: Permission denied; needsSudo is set when a sudo password would help.
* 400:
* description: Missing required parameters or SSH connection not established.
* 500:
* description: Failed to write file.
*/
app.post("/writeFile", async (req, res) => {
const { sessionId, path: filePath, content } = req.body;
const { sessionId, path: filePath, content, encoding } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
@@ -558,24 +579,9 @@ export function registerFileContentRoutes(
);
getSessionSftp(sshConn)
.then((sftp) => {
let fileBuffer;
let fileBuffer: Buffer;
try {
if (typeof content === "string") {
try {
const testBuffer = Buffer.from(content, "base64");
if (testBuffer.toString("base64") === content) {
fileBuffer = testBuffer;
} else {
fileBuffer = Buffer.from(content, "utf8");
}
} catch {
fileBuffer = Buffer.from(content, "utf8");
}
} else if (Buffer.isBuffer(content)) {
fileBuffer = content;
} else {
fileBuffer = Buffer.from(content);
}
fileBuffer = decodeWriteContent(content, encoding);
} catch (bufferErr) {
ctx.log.error("Buffer conversion error:", bufferErr as Error);
if (!res.headersSent) {
@@ -685,25 +691,12 @@ export function registerFileContentRoutes(
return;
}
try {
let contentBuffer: Buffer;
if (typeof content === "string") {
try {
contentBuffer = Buffer.from(content, "base64");
if (contentBuffer.toString("base64") !== content) {
contentBuffer = Buffer.from(content, "utf8");
}
} catch {
contentBuffer = Buffer.from(content, "utf8");
}
} else if (Buffer.isBuffer(content)) {
contentBuffer = content;
} else {
contentBuffer = Buffer.from(content);
}
const contentBuffer = decodeWriteContent(content, encoding);
const base64Content = contentBuffer.toString("base64");
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
// Content goes through stdin; a command line argument caps it at 128 KB.
const writeCommand = `cat > '${escapedPath}' && echo "SUCCESS"`;
execChannel(sshConn, writeCommand, (err, stream) => {
if (err) {
@@ -735,6 +728,8 @@ export function registerFileContentRoutes(
ctx.log.error("Fallback write stderr error:", stderrErr);
});
stream.end(contentBuffer);
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
restoreOriginalMode(null, () => {
@@ -788,7 +783,7 @@ export function registerFileContentRoutes(
`Fallback write failed with code ${code}: ${errorData}`,
);
if (!res.headersSent) {
res.status(500).json({
res.status(isPermDenied ? 403 : 500).json({
error: `Write failed: ${errorData}`,
needsSudo: isPermDenied,
toast: {
@@ -9,7 +9,9 @@ import {
writeSSHFile,
getSSHStatus,
connectSSH,
setSudoPassword,
} from "../api/ssh-file-operations-api";
import { SudoPasswordDialog } from "../SudoPasswordDialog.tsx";
import { toast } from "sonner";
import { DownloadProgressToast } from "./DownloadProgressToast";
import type { SSHHost } from "../host-types";
@@ -298,6 +300,21 @@ export function FileWindow({
loadFileContent();
};
const [sudoSaveContent, setSudoSaveContent] = useState<string | null>(null);
const handleSudoSave = async (password: string) => {
const pending = sudoSaveContent;
setSudoSaveContent(null);
if (pending === null) return;
try {
await setSudoPassword(sshSessionId, password);
} catch {
toast.error(t("fileManager.sudoAuthFailed"));
return;
}
await handleSave(pending);
};
const handleSave = async (newContent: string) => {
try {
setIsSaving(true);
@@ -317,13 +334,22 @@ export function FileWindow({
} catch (error: unknown) {
console.error("Failed to save file:", error);
const err = error as { message?: string };
if (
const err = error as {
message?: string;
response?: { data?: { needsSudo?: boolean } };
};
if (err.response?.data?.needsSudo) {
setSudoSaveContent(newContent);
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
toast.error(
@@ -563,6 +589,13 @@ export function FileWindow({
}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
<SudoPasswordDialog
open={sudoSaveContent !== null}
onOpenChange={(open) => {
if (!open) setSudoSaveContent(null);
}}
onSubmit={(password) => void handleSudoSave(password)}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { decodeWriteContent } from "../../src/backend/content-routes.js";
describe("decodeWriteContent", () => {
it("keeps text that happens to be valid base64", () => {
expect(decodeWriteContent("test").toString("utf8")).toBe("test");
expect(decodeWriteContent("abcd1234").toString("utf8")).toBe("abcd1234");
});
it("decodes base64 only when asked", () => {
const encoded = Buffer.from("local x = 1\n").toString("base64");
expect(decodeWriteContent(encoded, "base64").toString("utf8")).toBe(
"local x = 1\n",
);
});
it("passes buffers through", () => {
const buffer = Buffer.from([0, 1, 2]);
expect(decodeWriteContent(buffer)).toBe(buffer);
});
});