diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index 4f1487d34ea..cc3bf7df07c 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -1,7 +1,10 @@ +import syncFs from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import { openBoundaryFile, type BoundaryFileOpenResult } from "../infra/boundary-file-read.js"; +import { writeFileWithinRoot } from "../infra/fs-safe.js"; import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js"; import { applyUpdateHunk } from "./apply-patch-update.js"; import { assertSandboxPath, resolveSandboxInputPath } from "./sandbox-paths.js"; @@ -235,9 +238,37 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps { mkdirp: (dir) => bridge.mkdirp({ filePath: dir, cwd: root }), }; } + const workspaceOnly = options.workspaceOnly !== false; return { - readFile: (filePath) => fs.readFile(filePath, "utf8"), - writeFile: (filePath, content) => fs.writeFile(filePath, content, "utf8"), + readFile: async (filePath) => { + if (!workspaceOnly) { + return await fs.readFile(filePath, "utf8"); + } + const opened = await openBoundaryFile({ + absolutePath: filePath, + rootPath: options.cwd, + boundaryLabel: "workspace root", + }); + assertBoundaryRead(opened, filePath); + try { + return syncFs.readFileSync(opened.fd, "utf8"); + } finally { + syncFs.closeSync(opened.fd); + } + }, + writeFile: async (filePath, content) => { + if (!workspaceOnly) { + await fs.writeFile(filePath, content, "utf8"); + return; + } + const relative = toRelativeWorkspacePath(options.cwd, filePath); + await writeFileWithinRoot({ + rootDir: options.cwd, + relativePath: relative, + data: content, + encoding: "utf8", + }); + }, remove: (filePath) => fs.rm(filePath), mkdirp: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}), }; @@ -298,6 +329,27 @@ function resolvePathFromCwd(filePath: string, cwd: string): string { return path.normalize(resolveSandboxInputPath(filePath, cwd)); } +function toRelativeWorkspacePath(workspaceRoot: string, absolutePath: string): string { + const rootResolved = path.resolve(workspaceRoot); + const resolved = path.resolve(absolutePath); + const relative = path.relative(rootResolved, resolved); + if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${absolutePath}`); + } + return relative; +} + +function assertBoundaryRead( + opened: BoundaryFileOpenResult, + targetPath: string, +): asserts opened is Extract { + if (opened.ok) { + return; + } + const reason = opened.reason === "validation" ? "unsafe path" : "path not found"; + throw new Error(`Failed boundary read for ${targetPath} (${reason})`); +} + function toDisplayPath(resolved: string, cwd: string): string { const relative = path.relative(cwd, resolved); if (!relative || relative === "") { diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 4fe53c3317c..923be3aa7af 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -1,7 +1,9 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent"; +import { SafeOpenError, openFileWithinRoot, writeFileWithinRoot } from "../infra/fs-safe.js"; import { detectMime } from "../media/mime.js"; import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js"; import type { ImageSanitizationLimits } from "./image-sanitization.js"; @@ -665,6 +667,20 @@ export function createSandboxedEditTool(params: SandboxToolParams) { return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit); } +export function createHostWorkspaceWriteTool(root: string) { + const base = createWriteTool(root, { + operations: createHostWriteOperations(root), + }) as unknown as AnyAgentTool; + return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write); +} + +export function createHostWorkspaceEditTool(root: string) { + const base = createEditTool(root, { + operations: createHostEditOperations(root), + }) as unknown as AnyAgentTool; + return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit); +} + export function createOpenClawReadTool( base: AnyAgentTool, options?: OpenClawReadToolOptions, @@ -741,6 +757,87 @@ function createSandboxEditOperations(params: SandboxToolParams) { } as const; } +function createHostWriteOperations(root: string) { + return { + mkdir: async (dir: string) => { + const relative = toRelativePathInRoot(root, dir, { allowRoot: true }); + const resolved = relative ? path.resolve(root, relative) : path.resolve(root); + await assertSandboxPath({ filePath: resolved, cwd: root, root }); + await fs.mkdir(resolved, { recursive: true }); + }, + writeFile: async (absolutePath: string, content: string) => { + const relative = toRelativePathInRoot(root, absolutePath); + await writeFileWithinRoot({ + rootDir: root, + relativePath: relative, + data: content, + mkdir: true, + }); + }, + } as const; +} + +function createHostEditOperations(root: string) { + return { + readFile: async (absolutePath: string) => { + const relative = toRelativePathInRoot(root, absolutePath); + const opened = await openFileWithinRoot({ + rootDir: root, + relativePath: relative, + }); + try { + return await opened.handle.readFile(); + } finally { + await opened.handle.close().catch(() => {}); + } + }, + writeFile: async (absolutePath: string, content: string) => { + const relative = toRelativePathInRoot(root, absolutePath); + await writeFileWithinRoot({ + rootDir: root, + relativePath: relative, + data: content, + mkdir: true, + }); + }, + access: async (absolutePath: string) => { + const relative = toRelativePathInRoot(root, absolutePath); + try { + const opened = await openFileWithinRoot({ + rootDir: root, + relativePath: relative, + }); + await opened.handle.close().catch(() => {}); + } catch (error) { + if (error instanceof SafeOpenError && error.code === "not-found") { + throw createFsAccessError("ENOENT", absolutePath); + } + throw error; + } + }, + } as const; +} + +function toRelativePathInRoot( + root: string, + candidate: string, + options?: { allowRoot?: boolean }, +): string { + const rootResolved = path.resolve(root); + const resolved = path.resolve(candidate); + const relative = path.relative(rootResolved, resolved); + if (relative === "" || relative === ".") { + if (options?.allowRoot) { + return ""; + } + throw new Error(`Path escapes workspace root: ${candidate}`); + } + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Path escapes workspace root: ${candidate}`); + } + return relative; +} + function createFsAccessError(code: string, filePath: string): NodeJS.ErrnoException { const error = new Error(`Sandbox FS error (${code}): ${filePath}`) as NodeJS.ErrnoException; error.code = code; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index a06aba73beb..c5120f7438e 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -1,10 +1,4 @@ -import { - codingTools, - createEditTool, - createReadTool, - createWriteTool, - readTool, -} from "@mariozechner/pi-coding-agent"; +import { codingTools, createReadTool, readTool } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../config/config.js"; import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js"; @@ -34,7 +28,8 @@ import { } from "./pi-tools.policy.js"; import { assertRequiredParams, - CLAUDE_PARAM_GROUPS, + createHostWorkspaceEditTool, + createHostWorkspaceWriteTool, createOpenClawReadTool, createSandboxedEditTool, createSandboxedReadTool, @@ -364,22 +359,14 @@ export function createOpenClawCodingTools(options?: { if (sandboxRoot) { return []; } - // Wrap with param normalization for Claude Code compatibility - const wrapped = wrapToolParamNormalization( - createWriteTool(workspaceRoot), - CLAUDE_PARAM_GROUPS.write, - ); + const wrapped = createHostWorkspaceWriteTool(workspaceRoot); return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped]; } if (tool.name === "edit") { if (sandboxRoot) { return []; } - // Wrap with param normalization for Claude Code compatibility - const wrapped = wrapToolParamNormalization( - createEditTool(workspaceRoot), - CLAUDE_PARAM_GROUPS.edit, - ); + const wrapped = createHostWorkspaceEditTool(workspaceRoot); return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped]; } return [tool]; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index aa5f3b90ead..ed7b7330e91 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveControlUiRootSync } from "../infra/control-ui-assets.js"; import { isWithinDir } from "../infra/path-safety.js"; import { openVerifiedFileSync } from "../infra/safe-open-sync.js"; @@ -210,11 +211,6 @@ function serveResolvedIndexHtml(res: ServerResponse, body: string) { res.end(body); } -function isContainedPath(baseDir: string, targetPath: string): boolean { - const relative = path.relative(baseDir, targetPath); - return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); -} - function isExpectedSafePathError(error: unknown): boolean { const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : ""; @@ -237,25 +233,20 @@ function resolveSafeControlUiFile( rootReal: string, filePath: string, ): { path: string; fd: number } | null { - try { - const fileReal = fs.realpathSync(filePath); - if (!isContainedPath(rootReal, fileReal)) { - return null; + const opened = openBoundaryFileSync({ + absolutePath: filePath, + rootPath: rootReal, + rootRealPath: rootReal, + boundaryLabel: "control ui root", + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + if (opened.reason === "io") { + throw opened.error; } - const opened = openVerifiedFileSync({ filePath: fileReal, resolvedPath: fileReal }); - if (!opened.ok) { - if (opened.reason === "io") { - throw opened.error; - } - return null; - } - return { path: opened.path, fd: opened.fd }; - } catch (error) { - if (isExpectedSafePathError(error)) { - return null; - } - throw error; + return null; } + return { path: opened.path, fd: opened.fd }; } function isSafeRelativePath(relPath: string) { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 6098dd7be25..a59b689a27d 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -1,4 +1,3 @@ -import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -29,7 +28,7 @@ import { import { loadConfig, writeConfigFile } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; import { sameFileIdentity } from "../../infra/file-identity.js"; -import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js"; +import { SafeOpenError, readLocalFileSafely, writeFileWithinRoot } from "../../infra/fs-safe.js"; import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js"; import { isNotFoundPathError } from "../../infra/path-guards.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; @@ -121,13 +120,6 @@ type ResolvedAgentWorkspaceFilePath = reason: string; }; -const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; -const OPEN_WRITE_FLAGS = - fsConstants.O_WRONLY | - fsConstants.O_CREAT | - fsConstants.O_TRUNC | - (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); - async function resolveWorkspaceRealPath(workspaceDir: string): Promise { try { return await fs.realpath(workspaceDir); @@ -238,25 +230,6 @@ async function statFileSafely(filePath: string): Promise { } } -async function writeFileSafely(filePath: string, content: string): Promise { - const handle = await fs.open(filePath, OPEN_WRITE_FLAGS, 0o600); - try { - const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]); - if (lstat.isSymbolicLink() || !stat.isFile()) { - throw new Error("unsafe file path"); - } - if (stat.nlink > 1) { - throw new Error("hardlinked file path is not allowed"); - } - if (!sameFileIdentity(stat, lstat)) { - throw new Error("path changed during write"); - } - await handle.writeFile(content, "utf-8"); - } finally { - await handle.close().catch(() => {}); - } -} - async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) { const files: Array<{ name: string; @@ -729,7 +702,12 @@ export const agentsHandlers: GatewayRequestHandlers = { } const content = String(params.content ?? ""); try { - await writeFileSafely(resolvedPath.ioPath, content); + await writeFileWithinRoot({ + rootDir: workspaceDir, + relativePath: name, + data: content, + encoding: "utf8", + }); } catch { respond( false, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 14165ab2875..fa4c514388b 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -22,7 +22,7 @@ import { type SessionEntry, type SessionScope, } from "../config/sessions.js"; -import { openVerifiedFileSync } from "../infra/safe-open-sync.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { normalizeAgentId, normalizeMainKey, @@ -102,14 +102,13 @@ function resolveIdentityAvatarUrl( return undefined; } try { - const resolvedReal = fs.realpathSync(resolvedCandidate); - if (!isPathWithinRoot(workspaceRoot, resolvedReal)) { - return undefined; - } - const opened = openVerifiedFileSync({ - filePath: resolvedReal, - resolvedPath: resolvedReal, + const opened = openBoundaryFileSync({ + absolutePath: resolvedCandidate, + rootPath: workspaceRoot, + rootRealPath: workspaceRoot, + boundaryLabel: "workspace root", maxBytes: AVATAR_MAX_BYTES, + skipLexicalRootCheck: true, }); if (!opened.ok) { return undefined; diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index 02059149532..cb2399c616b 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; -import { SafeOpenError, openFileWithinRoot, readLocalFileSafely } from "./fs-safe.js"; +import { + SafeOpenError, + openFileWithinRoot, + readLocalFileSafely, + writeFileWithinRoot, +} from "./fs-safe.js"; const tempDirs = createTrackedTempDirs(); @@ -81,6 +86,83 @@ describe("fs-safe", () => { ).rejects.toMatchObject({ code: "invalid-path" }); }); + it.runIf(process.platform !== "win32")("blocks hardlink aliases under root", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const outside = await tempDirs.make("openclaw-fs-safe-outside-"); + const outsideFile = path.join(outside, "outside.txt"); + const hardlinkPath = path.join(root, "link.txt"); + await fs.writeFile(outsideFile, "outside"); + try { + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await expect( + openFileWithinRoot({ + rootDir: root, + relativePath: "link.txt", + }), + ).rejects.toMatchObject({ code: "invalid-path" }); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideFile, { force: true }); + } + }); + + it("writes a file within root safely", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + await writeFileWithinRoot({ + rootDir: root, + relativePath: "nested/out.txt", + data: "hello", + }); + await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello"); + }); + + it("rejects write traversal outside root", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + await expect( + writeFileWithinRoot({ + rootDir: root, + relativePath: "../escape.txt", + data: "x", + }), + ).rejects.toMatchObject({ code: "invalid-path" }); + }); + + it.runIf(process.platform !== "win32")("rejects writing through hardlink aliases", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const outside = await tempDirs.make("openclaw-fs-safe-outside-"); + const outsideFile = path.join(outside, "outside.txt"); + const hardlinkPath = path.join(root, "alias.txt"); + await fs.writeFile(outsideFile, "outside"); + try { + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await expect( + writeFileWithinRoot({ + rootDir: root, + relativePath: "alias.txt", + data: "pwned", + }), + ).rejects.toMatchObject({ code: "invalid-path" }); + await expect(fs.readFile(outsideFile, "utf8")).resolves.toBe("outside"); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideFile, { force: true }); + } + }); + it("returns not-found for missing files", async () => { const dir = await tempDirs.make("openclaw-fs-safe-"); const missing = path.join(dir, "missing.txt"); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index b42a109df98..4ac06f937fd 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; import { sameFileIdentity } from "./file-identity.js"; +import { assertNoPathAliasEscape } from "./path-alias-guards.js"; import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; export type SafeOpenErrorCode = @@ -38,10 +39,20 @@ export type SafeLocalReadResult = { const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_WRITE_FLAGS = + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); -async function openVerifiedLocalFile(filePath: string): Promise { +async function openVerifiedLocalFile( + filePath: string, + options?: { + rejectHardlinks?: boolean; + }, +): Promise { let handle: FileHandle; try { handle = await fs.open(filePath, OPEN_READ_FLAGS); @@ -63,12 +74,18 @@ async function openVerifiedLocalFile(filePath: string): Promise if (!stat.isFile()) { throw new SafeOpenError("not-file", "not a file"); } + if (options?.rejectHardlinks && stat.nlink > 1) { + throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); + } if (!sameFileIdentity(stat, lstat)) { throw new SafeOpenError("path-mismatch", "path changed during read"); } const realPath = await fs.realpath(filePath); const realStat = await fs.stat(realPath); + if (options?.rejectHardlinks && realStat.nlink > 1) { + throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); + } if (!sameFileIdentity(stat, realStat)) { throw new SafeOpenError("path-mismatch", "path mismatch"); } @@ -89,6 +106,7 @@ async function openVerifiedLocalFile(filePath: string): Promise export async function openFileWithinRoot(params: { rootDir: string; relativePath: string; + rejectHardlinks?: boolean; }): Promise { let rootReal: string; try { @@ -120,6 +138,11 @@ export async function openFileWithinRoot(params: { throw err; } + if (params.rejectHardlinks !== false && opened.stat.nlink > 1) { + await opened.handle.close().catch(() => {}); + throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); + } + if (!isPathInside(rootWithSep, opened.realPath)) { await opened.handle.close().catch(() => {}); throw new SafeOpenError("invalid-path", "path escapes root"); @@ -146,3 +169,100 @@ export async function readLocalFileSafely(params: { await opened.handle.close().catch(() => {}); } } + +export async function writeFileWithinRoot(params: { + rootDir: string; + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; +}): Promise { + let rootReal: string; + try { + rootReal = await fs.realpath(params.rootDir); + } catch (err) { + if (isNotFoundPathError(err)) { + throw new SafeOpenError("not-found", "root dir not found"); + } + throw err; + } + const rootWithSep = ensureTrailingSep(rootReal); + const resolved = path.resolve(rootWithSep, params.relativePath); + if (!isPathInside(rootWithSep, resolved)) { + throw new SafeOpenError("invalid-path", "path escapes root"); + } + try { + await assertNoPathAliasEscape({ + absolutePath: resolved, + rootPath: rootReal, + boundaryLabel: "root", + }); + } catch (err) { + throw new SafeOpenError("invalid-path", "path alias escape blocked", { cause: err }); + } + if (params.mkdir !== false) { + await fs.mkdir(path.dirname(resolved), { recursive: true }); + } + + let ioPath = resolved; + try { + const resolvedRealPath = await fs.realpath(resolved); + if (!isPathInside(rootWithSep, resolvedRealPath)) { + throw new SafeOpenError("invalid-path", "path escapes root"); + } + ioPath = resolvedRealPath; + } catch (err) { + if (err instanceof SafeOpenError) { + throw err; + } + if (!isNotFoundPathError(err)) { + throw err; + } + } + + let handle: FileHandle; + try { + handle = await fs.open(ioPath, OPEN_WRITE_FLAGS, 0o600); + } catch (err) { + if (isNotFoundPathError(err)) { + throw new SafeOpenError("not-found", "file not found"); + } + if (isSymlinkOpenError(err)) { + throw new SafeOpenError("invalid-path", "symlink open blocked", { cause: err }); + } + throw err; + } + + try { + const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(ioPath)]); + if (lstat.isSymbolicLink() || !stat.isFile()) { + throw new SafeOpenError("invalid-path", "path is not a regular file under root"); + } + if (stat.nlink > 1) { + throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); + } + if (!sameFileIdentity(stat, lstat)) { + throw new SafeOpenError("path-mismatch", "path changed during write"); + } + + const realPath = await fs.realpath(ioPath); + const realStat = await fs.stat(realPath); + if (!sameFileIdentity(stat, realStat)) { + throw new SafeOpenError("path-mismatch", "path mismatch"); + } + if (realStat.nlink > 1) { + throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); + } + if (!isPathInside(rootWithSep, realPath)) { + throw new SafeOpenError("invalid-path", "path escapes root"); + } + + if (typeof params.data === "string") { + await handle.writeFile(params.data, params.encoding ?? "utf8"); + } else { + await handle.writeFile(params.data); + } + } finally { + await handle.close().catch(() => {}); + } +}