UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

123 lines (122 loc) • 6.19 kB
import { randomBytes } from "node:crypto"; import { constants } from "node:fs"; import { lstat, open, realpath, rename, rm } from "node:fs/promises"; import path from "node:path"; import { fileVersion, MAX_FILE_RANGE } from "./file-range.js"; import { BridgeError } from "./protocol.js"; /** * Write one text file inside a repository root the server chose (a pane's * checkout), for the desktop editor. The same path rules as reads: relative, * no `..`, no `.git`, no symbolic link anywhere on the way, no control * characters. An update must name the version the editor read * (`fileVersion`) and fails with 409 `file-changed` when the file moved since; * leaving the version out creates a new file and fails with `file-exists` * when one is there. Updates go through a temp file in the same folder and a * rename, keeping the file's mode, so a reader never sees half a file. */ /** One write at a time per file in this Hook, so two saves naming the same * version cannot both pass the check and overwrite each other. */ const fileLocks = new Map(); async function serialized(key, work) { const previous = fileLocks.get(key) ?? Promise.resolve(); const run = previous.catch(() => undefined).then(work); fileLocks.set(key, run); try { return await run; } finally { if (fileLocks.get(key) === run) fileLocks.delete(key); } } export async function writeRepoFile(root, requested, content, expectedVersion) { // `f.txt` and `./f.txt` name one file, so they must share one lock. const name = typeof requested === "string" ? path.posix.normalize(requested) : requested; const key = `${await realpath(root).catch(() => root)}\u0000${name}`; return serialized(key, () => writeRepoFileNow(root, requested, content, expectedVersion)); } async function writeRepoFileNow(root, requested, content, expectedVersion) { if (typeof requested !== "string" || !requested || requested.length > 4096 || path.isAbsolute(requested) || /[\x00-\x1f\x7f\\]/.test(requested) || requested.split("/").some(part => part === ".." || part.toLowerCase() === ".git" || part === "")) { throw new BridgeError(400, "Invalid file path."); } if (typeof content !== "string") throw new BridgeError(400, "Supply the file's text."); const bytes = Buffer.from(content, "utf8"); if (bytes.length > MAX_FILE_RANGE) throw new BridgeError(413, "Files over 4 MiB cannot be saved from here."); if (expectedVersion !== undefined && (typeof expectedVersion !== "string" || expectedVersion.length > 200)) throw new BridgeError(400, "Invalid file version."); const base = await realpath(root); const parts = requested.split("/"); let directory = base; for (const part of parts.slice(0, -1)) { directory = path.join(directory, part); const info = await lstat(directory).catch(() => undefined); if (!info) throw new BridgeError(404, "That folder does not exist."); if (info.isSymbolicLink()) throw new BridgeError(403, "Symbolic links cannot be written through."); if (!info.isDirectory()) throw new BridgeError(400, "That folder is not a directory."); } if ((await realpath(directory)) !== directory) throw new BridgeError(403, "This file is outside the repository."); const file = path.join(directory, parts[parts.length - 1]); if (file !== base && !file.startsWith(base + path.sep)) throw new BridgeError(403, "This file is outside the repository."); const existing = await lstat(file).catch((error) => { if (error.code === "ENOENT") return undefined; throw error; }); if (existing?.isSymbolicLink()) throw new BridgeError(403, "Symbolic links cannot be written through."); if (existing && !existing.isFile()) throw new BridgeError(400, "Only regular files can be saved."); if (expectedVersion === undefined) { if (existing) throw new BridgeError(409, "A file with that name already exists.", { code: "file-exists" }); // Exclusive create: a file that appears in between is never overwritten. const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644).catch((error) => { if (error.code === "EEXIST") throw new BridgeError(409, "A file with that name already exists.", { code: "file-exists" }); throw error; }); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } const created = await lstat(file); return { path: requested, version: fileVersion(created), size: created.size, created: true }; } if (!existing) throw new BridgeError(409, "The file is gone. It changed since you opened it.", { code: "file-changed" }); if (fileVersion(existing) !== expectedVersion) throw new BridgeError(409, "The file changed since you opened it.", { code: "file-changed" }); const temporary = path.join(directory, `.${parts[parts.length - 1]}.phren-${randomBytes(6).toString("hex")}.tmp`); try { const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, existing.mode & 0o777); try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } // Recheck right before the swap so a write that landed meanwhile is not lost. const current = await lstat(file).catch(() => undefined); if (!current || current.isSymbolicLink() || fileVersion(current) !== expectedVersion) { throw new BridgeError(409, "The file changed since you opened it.", { code: "file-changed" }); } await rename(temporary, file); } finally { await rm(temporary, { force: true }); } const saved = await lstat(file); return { path: requested, version: fileVersion(saved), size: saved.size, created: false }; }