From bd9af256bdbd29815c90e98fb352c0ceb4e8e31c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:06:36 +0200 Subject: [PATCH] Keep a symlinked config.json linked when Paseo saves it (#6023) * fix(server): keep a symlinked config.json linked when saving Paseo saved config.json by renaming a temporary file over its path, which replaced a symlink with a regular file and left the linked file unchanged. Private file writes now resolve the symlink and replace the file it points at. Closes #5932 * test(server): check a symlinked config.json stays private --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/persisted-config.test.ts | 44 ++++++++++++++++++- packages/server/src/server/private-files.ts | 23 +++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index f443f84865..4c626fde8f 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,4 +1,14 @@ -import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; @@ -843,3 +853,35 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); }); + +describe.skipIf(process.platform === "win32")( + "config.json symlinked from another directory", + () => { + test("saving writes through the symlink and leaves it in place", () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); + + savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + }, +); diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 8b162f292b..0e12457be1 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -28,14 +28,27 @@ export function writePrivateFileAtomicSync( data: string | NodeJS.ArrayBufferView, ): void { ensurePrivateDirectory(path.dirname(filePath)); - const parent = path.dirname(filePath); - const temporary = path.join(parent, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}`); + const destination = resolveSymlinkedFile(filePath); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}`, + ); try { writeFileSync(temporary, data, { mode: PRIVATE_FILE_MODE }); - renameSync(temporary, filePath); - ensurePrivateFile(filePath); + renameSync(temporary, destination); + ensurePrivateFile(destination); } catch (error) { rmSync(temporary, { force: true }); throw error; } } + +// Renaming onto a symlink replaces the link, so write to the file it points at. +function resolveSymlinkedFile(filePath: string): string { + try { + return realpathSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return filePath; + throw error; + } +}