infra: preserve symlink write semantics

This commit is contained in:
Gustavo Madeira Santana
2026-04-03 20:10:10 -04:00
parent 434006c0a3
commit cb8ed77049
2 changed files with 27 additions and 5 deletions

View File

@@ -142,6 +142,24 @@ describe("json-file helpers", () => {
},
);
it.runIf(process.platform !== "win32")(
"does not create missing target directories through an existing symlink",
async () => {
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
const missingTargetDir = path.join(root, "missing-target");
const targetPath = path.join(missingTargetDir, "config.json");
const linkPath = path.join(root, "config-link.json");
fs.symlinkSync(targetPath, linkPath);
expect(() => saveJsonFile(linkPath, SAVED_PAYLOAD)).toThrow(
expect.objectContaining({ code: "ENOENT" }),
);
expect(fs.existsSync(missingTargetDir)).toBe(false);
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
});
},
);
it("falls back to copy when rename-based overwrite fails", async () => {
await withJsonPath(({ root, pathname }) => {
writeExistingJson(pathname);

View File

@@ -36,9 +36,10 @@ function readSymlinkTargetPath(linkPath: string): string {
return path.resolve(path.dirname(linkPath), target);
}
function resolveJsonWriteTarget(pathname: string): string {
function resolveJsonWriteTarget(pathname: string): { targetPath: string; followsSymlink: boolean } {
let currentPath = pathname;
const visited = new Set<string>();
let followsSymlink = false;
for (;;) {
let stat: fs.Stats;
@@ -48,11 +49,11 @@ function resolveJsonWriteTarget(pathname: string): string {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return currentPath;
return { targetPath: currentPath, followsSymlink };
}
if (!stat.isSymbolicLink()) {
return currentPath;
return { targetPath: currentPath, followsSymlink };
}
if (visited.has(currentPath)) {
@@ -64,6 +65,7 @@ function resolveJsonWriteTarget(pathname: string): string {
}
visited.add(currentPath);
followsSymlink = true;
currentPath = readSymlinkTargetPath(currentPath);
}
}
@@ -104,11 +106,13 @@ export function loadJsonFile<T = unknown>(pathname: string): T | undefined {
}
export function saveJsonFile(pathname: string, data: unknown) {
const targetPath = resolveJsonWriteTarget(pathname);
const { targetPath, followsSymlink } = resolveJsonWriteTarget(pathname);
const tmpPath = `${targetPath}.${randomUUID()}.tmp`;
const payload = `${JSON.stringify(data, null, 2)}\n`;
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: JSON_DIR_MODE });
if (!followsSymlink) {
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: JSON_DIR_MODE });
}
try {
writeTempJsonFile(tmpPath, payload);
trySetSecureMode(tmpPath);