Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 28 additions & 31 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ import {
BedrockTestConfig,
BedrockTestState,
DiscardAction,
SaveFilePayload,
OpenFileResult,
OpenSpecificFilePayload,
SaveFileResult,
ExportFilePayload,
} from "../shared/types";
import {
MAX_MARKDOWN_FILE_BYTES,
safeExportBaseName,
validateExportFilePayload,
validateSaveFilePayload,
} from "./ipcValidation";
import {
buildRuntimeInfo,
captureMainTelemetryException,
Expand All @@ -32,8 +36,6 @@ const MARKDOWN_DIALOG_FILTER = {
name: "Markdown Files",
extensions: ["md"],
};
const MAX_MARKDOWN_FILE_BYTES = 10 * 1024 * 1024;
const MAX_EXPORT_HTML_BYTES = 25 * 1024 * 1024;

const ensureMarkdownExtension = (filePath: string): string => {
return filePath.toLowerCase().endsWith(".md") ? filePath : `${filePath}.md`;
Expand Down Expand Up @@ -133,16 +135,6 @@ const normalizeMarkdownFilePath = (filePath: unknown): string | null => {
return isMarkdownFilePath(resolvedPath) ? resolvedPath : null;
};

const assertReasonableContentSize = (
content: unknown,
maxBytes: number
): content is string => {
return (
typeof content === "string" &&
Buffer.byteLength(content, "utf-8") <= maxBytes
);
};

const readMarkdownFile = async (
filePath: string
): Promise<OpenFileResult | null> => {
Expand Down Expand Up @@ -285,15 +277,19 @@ ipcMain.handle("file:consume-pending-external-open", () => {

ipcMain.handle(
"file:save",
async (event, args: SaveFilePayload): Promise<SaveFileResult | null> => {
async (event, args: unknown): Promise<SaveFileResult | null> => {
let telemetryFilePath: string | undefined;
try {
if (!assertReasonableContentSize(args.content, MAX_MARKDOWN_FILE_BYTES)) {
throw new Error("Markdown content is too large to save.");
const validation = validateSaveFilePayload(args);
if (validation.ok === false) {
throw new Error(validation.message);
}
const payload = validation.payload;
telemetryFilePath = payload.filePath;

let targetPath = args.filePath
? ensureMarkdownExtension(path.resolve(args.filePath))
: args.filePath;
let targetPath = payload.filePath
? ensureMarkdownExtension(path.resolve(payload.filePath))
: payload.filePath;

if (!targetPath) {
const nextSavePath = resolveNextSavePath();
Expand All @@ -316,14 +312,14 @@ ipcMain.handle(
}
}

await fs.writeFile(targetPath, args.content, "utf-8");
await fs.writeFile(targetPath, payload.content, "utf-8");
return { filePath: targetPath };
} catch (error) {
const message =
error instanceof Error ? error.message : "An unknown error occurred.";
captureMainTelemetryException(error, {
operation: "file:save",
filePath: args?.filePath,
filePath: telemetryFilePath,
});
Comment on lines 318 to 323
dialog.showErrorBox("Unable to save file", message);
return null;
Expand Down Expand Up @@ -402,23 +398,24 @@ ipcMain.handle("test:simulate-external-open", (_event, filePath: string) => {

ipcMain.handle(
"file:export",
async (event, args: ExportFilePayload): Promise<boolean> => {
async (event, args: unknown): Promise<boolean> => {
let telemetryFormat: string | undefined;
try {
const { content, format, defaultFileName } = args;
if (format !== "html" && format !== "pdf") {
throw new Error("Unsupported export format.");
}
if (!assertReasonableContentSize(content, MAX_EXPORT_HTML_BYTES)) {
throw new Error("Export content is too large.");
const validation = validateExportFilePayload(args);
if (validation.ok === false) {
throw new Error(validation.message);
}
const payload = validation.payload;
const { content, format, defaultFileName } = payload;
telemetryFormat = format;

const extension = format === "html" ? "html" : "pdf";
const filters =
format === "html"
? [{ name: "HTML Files", extensions: ["html"] }]
: [{ name: "PDF Files", extensions: ["pdf"] }];

const baseName = defaultFileName || "Exported";
const baseName = safeExportBaseName(defaultFileName);

const { canceled, filePath } = await dialog.showSaveDialog(
BrowserWindow.fromWebContents(event.sender) ?? undefined,
Expand Down Expand Up @@ -507,7 +504,7 @@ ipcMain.handle(
error instanceof Error ? error.message : "An unknown error occurred.";
captureMainTelemetryException(error, {
operation: "file:export",
format: args?.format,
format: telemetryFormat,
});
dialog.showErrorBox("Unable to export file", message);
return false;
Expand Down
146 changes: 146 additions & 0 deletions src/main/ipcValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { ExportFilePayload, SaveFilePayload } from "../shared/types";

export const MAX_MARKDOWN_FILE_BYTES = 10 * 1024 * 1024;
export const MAX_EXPORT_HTML_BYTES = 25 * 1024 * 1024;

const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null;
};

const hasOwn = (value: Record<string, unknown>, key: string): boolean => {
return Object.prototype.hasOwnProperty.call(value, key);
};

type ValidationResult<T> =
| { ok: true; payload: T }
| { ok: false; message: string };

export const hasReasonableContentSize = (
content: unknown,
maxBytes: number
): content is string => {
return (
typeof content === "string" &&
Buffer.byteLength(content, "utf-8") <= maxBytes
);
};

export const normalizeSaveFilePayload = (
payload: unknown
): SaveFilePayload | null => {
const result = validateSaveFilePayload(payload);
return result.ok ? result.payload : null;
};

export const validateSaveFilePayload = (
payload: unknown
): ValidationResult<SaveFilePayload> => {
if (!isRecord(payload)) {
return { ok: false, message: "Invalid save payload." };
}
if (!hasReasonableContentSize(payload.content, MAX_MARKDOWN_FILE_BYTES)) {
return {
ok: false,
message:
typeof payload.content === "string"
? "Markdown content is too large to save."
: "Save content must be text.",
};
}
const hasFilePath = hasOwn(payload, "filePath");
if (
hasFilePath &&
payload.filePath !== undefined &&
(typeof payload.filePath !== "string" || payload.filePath.trim() === "")
) {
return { ok: false, message: "Save file path must be a non-empty string." };
}
Comment on lines +51 to +57

const filePath =
hasFilePath && typeof payload.filePath === "string"
? payload.filePath
: undefined;

return {
ok: true,
payload: {
content: payload.content,
filePath,
},
};
};

export const normalizeExportFilePayload = (
payload: unknown
): ExportFilePayload | null => {
const result = validateExportFilePayload(payload);
return result.ok ? result.payload : null;
};

export const validateExportFilePayload = (
payload: unknown
): ValidationResult<ExportFilePayload> => {
if (!isRecord(payload)) {
return { ok: false, message: "Invalid export payload." };
}
if (payload.format !== "html" && payload.format !== "pdf") {
return { ok: false, message: "Unsupported export format." };
}
if (!hasReasonableContentSize(payload.content, MAX_EXPORT_HTML_BYTES)) {
return {
ok: false,
message:
typeof payload.content === "string"
? "Export content is too large."
: "Export content must be text.",
};
}
const hasDefaultFileName = hasOwn(payload, "defaultFileName");
if (
hasDefaultFileName &&
payload.defaultFileName !== undefined &&
typeof payload.defaultFileName !== "string"
) {
return {
ok: false,
message: "Export default filename must be a string.",
};
}
Comment on lines +99 to +108

const defaultFileName =
hasDefaultFileName && typeof payload.defaultFileName === "string"
? payload.defaultFileName
: undefined;

return {
ok: true,
payload: {
content: payload.content,
format: payload.format,
defaultFileName,
},
};
};

export const safeExportBaseName = (
defaultFileName: string | undefined
): string => {
const rawName = defaultFileName || "Exported";
const lastSegment = rawName.split(/[\\/]/).pop() ?? "";
const withoutExtension = lastSegment.replace(/\.(?:html|pdf)$/i, "").trim();
const reservedCharacters = '<>:"|?*';
const sanitized = [...withoutExtension]
.map((char) =>
char.charCodeAt(0) < 32 || reservedCharacters.includes(char) ? "-" : char
)
.join("")
.replace(/[ .]+$/g, "");

if (!sanitized) {
return "Exported";
}
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(sanitized)) {
return `${sanitized}-file`;
}
return sanitized;
};
1 change: 1 addition & 0 deletions tests/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
import "./controllerShortcuts.test";
import "./ipcValidation.test";
import "./themeSettings.test";
110 changes: 110 additions & 0 deletions tests/ipcValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { strict as assert } from "assert";
import {
MAX_EXPORT_HTML_BYTES,
MAX_MARKDOWN_FILE_BYTES,
normalizeExportFilePayload,
normalizeSaveFilePayload,
safeExportBaseName,
validateExportFilePayload,
validateSaveFilePayload,
} from "../src/main/ipcValidation";

const runTest = (name: string, fn: () => void) => {
try {
fn();
console.log(`✓ ${name}`);
} catch (error) {
console.error(`✗ ${name}`);
console.error(error);
process.exitCode = 1;
}
};

runTest("save payload validation rejects malformed file paths", () => {
assert.equal(normalizeSaveFilePayload({ content: "ok", filePath: "" }), null);
assert.equal(
normalizeSaveFilePayload({ content: "ok", filePath: 123 }),
null
);
});

runTest("save payload validation rejects oversized content", () => {
const oversized = "x".repeat(MAX_MARKDOWN_FILE_BYTES + 1);

assert.equal(normalizeSaveFilePayload({ content: oversized }), null);
});

runTest("save payload validation reports specific errors", () => {
assert.deepEqual(validateSaveFilePayload({ content: 12 }), {
ok: false,
message: "Save content must be text.",
});
assert.deepEqual(validateSaveFilePayload({ content: "ok", filePath: "" }), {
ok: false,
message: "Save file path must be a non-empty string.",
});
});

runTest("save payload validation accepts valid content", () => {
assert.deepEqual(normalizeSaveFilePayload({ content: "ok" }), {
content: "ok",
filePath: undefined,
});
});

runTest("export payload validation rejects unsupported formats", () => {
assert.equal(
normalizeExportFilePayload({ content: "<p>ok</p>", format: "docx" }),
null
);
});

runTest("export payload validation rejects oversized content", () => {
const oversized = "x".repeat(MAX_EXPORT_HTML_BYTES + 1);

assert.equal(
normalizeExportFilePayload({ content: oversized, format: "html" }),
null
);
assert.deepEqual(
validateExportFilePayload({ content: oversized, format: "html" }),
{ ok: false, message: "Export content is too large." }
);
});

runTest("export payload validation rejects non-string default names", () => {
assert.equal(
normalizeExportFilePayload({
content: "<p>ok</p>",
format: "html",
defaultFileName: 12,
}),
null
);
});

runTest("export payload validation accepts valid payloads", () => {
assert.deepEqual(
normalizeExportFilePayload({
content: "<p>ok</p>",
format: "pdf",
defaultFileName: "Notes",
}),
{
content: "<p>ok</p>",
format: "pdf",
defaultFileName: "Notes",
}
);
});

runTest("export default names are sanitized to a base filename", () => {
assert.equal(safeExportBaseName("../notes:bad?.html"), "notes-bad-");
assert.equal(safeExportBaseName("CON.pdf"), "CON-file");
assert.equal(safeExportBaseName("notes. "), "notes");
assert.equal(safeExportBaseName(""), "Exported");
});
Comment on lines +55 to +106

if (process.exitCode && process.exitCode !== 0) {
throw new Error("One or more tests failed.");
}
Loading