Load config.json saved with a UTF-8 byte order mark (#5315)

* Load config.json saved with a UTF-8 byte order mark

Windows Notepad saves UTF-8 with a BOM. Both config readers passed the
text straight to JSON.parse, so the daemon refused to start and CLI
config commands failed with "Unexpected token".

Closes #4683

* Strip the config BOM with an anchored replace

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-24 03:48:52 +02:00
committed by GitHub
parent e6085c1e98
commit c356394bfa
2 changed files with 38 additions and 2 deletions
@@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest";
import {
loadPersistedConfig,
PersistedConfigSchema,
readPersistedConfig,
savePersistedConfig,
} from "./persisted-config.js";
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
@@ -736,6 +737,34 @@ describe("loadPersistedConfig", () => {
});
});
describe("config.json saved with a UTF-8 byte order mark", () => {
// Windows Notepad writes this shape: a BOM, then CRLF line endings.
const notepadConfig =
'{\r\n "version": 1,\r\n "daemon": { "listen": "127.0.0.1:6767" }\r\n}\r\n';
test("loadPersistedConfig reads it", () => {
const home = createTempHome();
try {
writeFileSync(path.join(home, "config.json"), notepadConfig);
expect(loadPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("readPersistedConfig reads it", () => {
const home = createTempHome();
try {
writeFileSync(path.join(home, "config.json"), notepadConfig);
expect(readPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
test("initializes config.json with private permissions", () => {
const home = createTempHome();
@@ -438,7 +438,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
let parsed: unknown;
try {
parsed = JSON.parse(raw);
parsed = parseConfigText(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
@@ -472,7 +472,14 @@ export function readPersistedConfig(
return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {};
throw error;
}
return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig;
return PersistedConfigSchema.parse(
stripRemovedConfigFields(parseConfigText(raw)),
) as PersistedConfig;
}
/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */
function parseConfigText(raw: string): unknown {
return JSON.parse(raw.replace(/^\uFEFF/, ""));
}
function configPathParts(field: string): string[] {