fix: protect bootstrap files during memory flush (#38574)

Merged via squash.

Prepared head SHA: a0b9a02e2e
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
Frank Yang
2026-03-10 12:44:33 +08:00
committed by GitHub
parent 989ee21b24
commit 96e4975922
13 changed files with 468 additions and 15 deletions

View File

@@ -7,6 +7,7 @@ import {
} from "../test-utils/symlink-rebind-race.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
appendFileWithinRoot,
copyFileWithinRoot,
createRootScopedReadFile,
SafeOpenError,
@@ -246,6 +247,22 @@ describe("fs-safe", () => {
await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello");
});
it("appends to a file within root safely", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const targetPath = path.join(root, "nested", "out.txt");
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, "seed");
await appendFileWithinRoot({
rootDir: root,
relativePath: "nested/out.txt",
data: "next",
prependNewlineIfNeeded: true,
});
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("seed\nnext");
});
it("does not truncate existing target when atomic rename fails", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const targetPath = path.join(root, "nested", "out.txt");
@@ -439,6 +456,25 @@ describe("fs-safe", () => {
});
});
it.runIf(process.platform !== "win32")("rejects appending through hardlink aliases", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const hardlinkPath = path.join(root, "alias.txt");
await withOutsideHardlinkAlias({
aliasPath: hardlinkPath,
run: async (outsideFile) => {
await expect(
appendFileWithinRoot({
rootDir: root,
relativePath: "alias.txt",
data: "pwned",
prependNewlineIfNeeded: true,
}),
).rejects.toMatchObject({ code: "invalid-path" });
await expect(fs.readFile(outsideFile, "utf8")).resolves.toBe("outside");
},
});
});
it("does not truncate out-of-root file when symlink retarget races write open", async () => {
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({
seedInsideTarget: true,
@@ -459,6 +495,27 @@ describe("fs-safe", () => {
await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
});
it("does not clobber out-of-root file when symlink retarget races append open", async () => {
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({
seedInsideTarget: true,
});
await expectSymlinkWriteRaceRejectsOutside({
slotPath: slot,
outsideDir: outside,
runWrite: async (relativePath) =>
await appendFileWithinRoot({
rootDir: root,
relativePath,
data: "new-content",
mkdir: false,
prependNewlineIfNeeded: true,
}),
});
await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
});
it("does not clobber out-of-root file when symlink retarget races write-from-path open", async () => {
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture();
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");

View File

@@ -57,6 +57,14 @@ const OPEN_WRITE_CREATE_FLAGS =
fsConstants.O_CREAT |
fsConstants.O_EXCL |
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
const OPEN_APPEND_EXISTING_FLAGS =
fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
const OPEN_APPEND_CREATE_FLAGS =
fsConstants.O_RDWR |
fsConstants.O_APPEND |
fsConstants.O_CREAT |
fsConstants.O_EXCL |
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep);
@@ -375,6 +383,7 @@ export async function openWritableFileWithinRoot(params: {
mkdir?: boolean;
mode?: number;
truncateExisting?: boolean;
append?: boolean;
}): Promise<SafeWritableOpenResult> {
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
try {
@@ -410,14 +419,16 @@ export async function openWritableFileWithinRoot(params: {
let handle: FileHandle;
let createdForWrite = false;
const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS;
const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS;
try {
try {
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode);
handle = await fs.open(ioPath, existingFlags, fileMode);
} catch (err) {
if (!isNotFoundPathError(err)) {
throw err;
}
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode);
handle = await fs.open(ioPath, createFlags, fileMode);
createdForWrite = true;
}
} catch (err) {
@@ -469,7 +480,7 @@ export async function openWritableFileWithinRoot(params: {
// Truncate only after boundary and identity checks complete. This avoids
// irreversible side effects if a symlink target changes before validation.
if (params.truncateExisting !== false && !createdForWrite) {
if (params.append !== true && params.truncateExisting !== false && !createdForWrite) {
await handle.truncate(0);
}
return {
@@ -489,6 +500,50 @@ export async function openWritableFileWithinRoot(params: {
}
}
export async function appendFileWithinRoot(params: {
rootDir: string;
relativePath: string;
data: string | Buffer;
encoding?: BufferEncoding;
mkdir?: boolean;
prependNewlineIfNeeded?: boolean;
}): Promise<void> {
const target = await openWritableFileWithinRoot({
rootDir: params.rootDir,
relativePath: params.relativePath,
mkdir: params.mkdir,
truncateExisting: false,
append: true,
});
try {
let prefix = "";
if (
params.prependNewlineIfNeeded === true &&
!target.createdForWrite &&
target.openedStat.size > 0 &&
((typeof params.data === "string" && !params.data.startsWith("\n")) ||
(Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a))
) {
const lastByte = Buffer.alloc(1);
const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.openedStat.size - 1);
if (bytesRead === 1 && lastByte[0] !== 0x0a) {
prefix = "\n";
}
}
if (typeof params.data === "string") {
await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8");
return;
}
const payload =
prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
await target.handle.appendFile(payload);
} finally {
await target.handle.close().catch(() => {});
}
}
export async function writeFileWithinRoot(params: {
rootDir: string;
relativePath: string;