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>
This commit is contained in:
paseo-bot[bot]
2026-10-14 03:06:36 +02:00
committed by GitHub
parent b1a638482d
commit bd9af256bd
2 changed files with 61 additions and 6 deletions
@@ -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 });
}
});
},
);
+18 -5
View File
@@ -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;
}
}