diff --git a/electron/bridges/sessionLogStreamManager.cjs b/electron/bridges/sessionLogStreamManager.cjs index aae2ab6e..7db13ec5 100644 --- a/electron/bridges/sessionLogStreamManager.cjs +++ b/electron/bridges/sessionLogStreamManager.cjs @@ -7,6 +7,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { + safePathSegment, toLocalISOString, wrapTerminalHtmlContent, } = require("./sessionLogsBridge.cjs"); @@ -51,7 +52,7 @@ function startStream(sessionId, opts) { try { // Build file path: directory / hostSubdir / timestamp.ext - const safeHostLabel = (hostLabel || hostname || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_"); + const safeHostLabel = safePathSegment(hostLabel || hostname, "unknown"); const hostDir = path.join(directory, safeHostLabel); fs.mkdirSync(hostDir, { recursive: true }); diff --git a/electron/bridges/sessionLogStreamManager.test.cjs b/electron/bridges/sessionLogStreamManager.test.cjs index d6392289..85ef34f9 100644 --- a/electron/bridges/sessionLogStreamManager.test.cjs +++ b/electron/bridges/sessionLogStreamManager.test.cjs @@ -139,6 +139,31 @@ test("stopStream without a token still tears down the current stream (back-compa } }); +test("startStream host directory preserves valid Unicode labels and replaces path-unsafe characters", async () => { + const directory = path.join(TEMP_ROOT, `stream-unicode-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + try { + const token = startStream(sessionId, { + hostLabel: "生产/服务器:东京*?<>|\0", + hostname: "fallback.example", + directory, + format: "raw", + startTime: Date.UTC(2026, 0, 2, 3, 4, 5), + }); + + assert.ok(token, "startStream should return a token"); + appendData(sessionId, "data\n"); + const finalPath = await stopStream(sessionId, token); + + assert.equal(path.basename(path.dirname(finalPath)), "生产_服务器_东京______"); + assert.equal(fs.readFileSync(finalPath, "utf8"), "data\n"); + } finally { + await stopStream(sessionId); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + async function waitForFileContent(directory, expectedContent) { const deadline = Date.now() + 3000; let lastContent = ""; diff --git a/electron/bridges/sessionLogsBridge.cjs b/electron/bridges/sessionLogsBridge.cjs index e3cff486..7667388d 100644 --- a/electron/bridges/sessionLogsBridge.cjs +++ b/electron/bridges/sessionLogsBridge.cjs @@ -11,6 +11,14 @@ const { terminalDataToPlainText, } = require("./terminalLogSanitizer.cjs"); +const FILE_NAME_UNSAFE_CHARS = new Set(["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]); +const WINDOWS_RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i; + +function isControlCharacter(char) { + const code = char.codePointAt(0); + return code !== undefined && ((code >= 0 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f)); +} + /** * Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS) */ @@ -27,6 +35,25 @@ function toLocalISOString(date = new Date()) { return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`; } +function safePathSegment(value, fallback = "unknown") { + const raw = String(value || ""); + let safe = Array.from(raw, (char) => { + return FILE_NAME_UNSAFE_CHARS.has(char) || isControlCharacter(char) ? "_" : char; + }).join("").trim(); + + if (!safe || safe === "." || safe === "..") { + return fallback; + } + + safe = safe.replace(/\.+$/g, (match) => "_".repeat(match.length)); + + if (WINDOWS_RESERVED_DEVICE_NAME.test(safe)) { + safe = `${safe}_`; + } + + return safe; +} + /** * Escape HTML special characters to prevent XSS * Must be applied before converting ANSI codes to HTML spans @@ -105,7 +132,7 @@ async function exportSessionLog(event, payload) { // Generate default filename const date = new Date(startTime); const dateStr = toLocalISOString(date); - const safeHostLabel = (hostLabel || hostname || "session").replace(/[^a-zA-Z0-9-_]/g, "_"); + const safeHostLabel = safePathSegment(hostLabel || hostname, "session"); const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt"; const defaultPath = `${safeHostLabel}_${dateStr}.${ext}`; @@ -172,7 +199,7 @@ async function autoSaveSessionLog(event, payload) { try { // Create host subdirectory - const safeHostLabel = (hostLabel || hostname || hostId || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_"); + const safeHostLabel = safePathSegment(hostLabel || hostname || hostId, "unknown"); const hostDir = path.join(directory, safeHostLabel); await fs.promises.mkdir(hostDir, { recursive: true }); @@ -244,4 +271,5 @@ module.exports = { terminalDataToHtml, terminalPlainTextToHtml, wrapTerminalHtmlContent, + safePathSegment, }; diff --git a/electron/bridges/sessionLogsBridge.test.cjs b/electron/bridges/sessionLogsBridge.test.cjs new file mode 100644 index 00000000..3de3f793 --- /dev/null +++ b/electron/bridges/sessionLogsBridge.test.cjs @@ -0,0 +1,108 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const Module = require("node:module"); + +const TEMP_ROOT = path.join(__dirname, ".tmp-session-logs-bridge-tests"); + +function loadBridgeWithDialog(dialogMock) { + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (request === "electron") { + return { dialog: dialogMock }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + const bridgePath = require.resolve("./sessionLogsBridge.cjs"); + delete require.cache[bridgePath]; + return require("./sessionLogsBridge.cjs"); + } finally { + Module._load = originalLoad; + } +} + +test("manual export default filename preserves valid Unicode host labels and replaces dangerous characters", async () => { + let defaultPath = ""; + const dialogMock = { + showSaveDialog: async (options) => { + defaultPath = options.defaultPath; + return { canceled: true }; + }, + }; + const { exportSessionLog } = loadBridgeWithDialog(dialogMock); + + const result = await exportSessionLog(null, { + terminalData: "hello\n", + hostLabel: "生产/服务器:东京*?<>|\0", + hostname: "fallback.example", + startTime: new Date(2026, 0, 2, 3, 4, 5).getTime(), + format: "txt", + }); + + assert.deepEqual(result, { success: false, canceled: true }); + assert.equal(defaultPath, "生产_服务器_东京_______2026-01-02T03-04-05.txt"); + assert.equal(defaultPath.includes("/"), false); + assert.equal(defaultPath.includes(":"), false); + assert.equal(defaultPath.includes("\0"), false); +}); + +test("safe path segments replace invisible control characters and protected names", () => { + const { safePathSegment } = loadBridgeWithDialog({}); + + assert.equal(safePathSegment("\t生产服务器\n", "fallback"), "_生产服务器_"); + assert.equal(safePathSegment("生产\u0085服务器\u009b", "fallback"), "生产_服务器_"); + assert.equal(safePathSegment("../name", "fallback"), ".._name"); + assert.equal(safePathSegment("CON", "fallback"), "CON_"); + assert.equal(safePathSegment("COM¹", "fallback"), "COM¹_"); + assert.equal(safePathSegment("LPT².txt", "fallback"), "LPT².txt_"); + assert.equal(safePathSegment("prod.", "fallback"), "prod_"); + assert.equal(safePathSegment("prod..", "fallback"), "prod__"); +}); + +test("auto-save host directory preserves valid Unicode labels and replaces path-unsafe characters", async () => { + const directory = path.join(TEMP_ROOT, `auto-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const { autoSaveSessionLog } = loadBridgeWithDialog({}); + + try { + const result = await autoSaveSessionLog(null, { + terminalData: "hello\n", + hostLabel: "生产/服务器:东京*?<>|\0", + hostname: "fallback.example", + hostId: "host-id", + startTime: Date.UTC(2026, 0, 2, 3, 4, 5), + format: "raw", + directory, + }); + + assert.equal(result.success, true); + assert.equal(path.basename(path.dirname(result.filePath)), "生产_服务器_东京______"); + assert.equal(fs.readFileSync(result.filePath, "utf8"), "hello\n"); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("auto-save host directory falls back when the sanitized host label is empty", async () => { + const directory = path.join(TEMP_ROOT, `auto-empty-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const { autoSaveSessionLog } = loadBridgeWithDialog({}); + + try { + const result = await autoSaveSessionLog(null, { + terminalData: "hello\n", + hostLabel: " ", + hostname: "", + hostId: "", + startTime: Date.UTC(2026, 0, 2, 3, 4, 5), + format: "txt", + directory, + }); + + assert.equal(result.success, true); + assert.equal(path.basename(path.dirname(result.filePath)), "unknown"); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +});