mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-25 08:03:32 +00:00
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:
@@ -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-");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user