Skip to content
Merged
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
3 changes: 2 additions & 1 deletion electron/bridges/sessionLogStreamManager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const fs = require("node:fs");
const path = require("node:path");
const {
safePathSegment,
toLocalISOString,
wrapTerminalHtmlContent,
} = require("./sessionLogsBridge.cjs");
Expand Down Expand Up @@ -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 });

Expand Down
25 changes: 25 additions & 0 deletions electron/bridges/sessionLogStreamManager.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
32 changes: 30 additions & 2 deletions electron/bridges/sessionLogsBridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand All @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize trailing periods in sanitized path segments

The sanitizer now allows . within host directory names and does not strip trailing periods, so labels like prod. are returned as-is. Windows normalizes trailing periods on create/open, so prod and prod. can collapse to the same directory and mix logs, which is a regression from the previous ASCII-only replacement behavior.

Useful? React with 👍 / 👎.

}

/**
* Escape HTML special characters to prevent XSS
* Must be applied before converting ANSI codes to HTML spans
Expand Down Expand Up @@ -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}`;

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -244,4 +271,5 @@ module.exports = {
terminalDataToHtml,
terminalPlainTextToHtml,
wrapTerminalHtmlContent,
safePathSegment,
};
108 changes: 108 additions & 0 deletions electron/bridges/sessionLogsBridge.test.cjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading