/** * @file Saves one text file in a lane's working tree, for the Changes panel's * single-file editor. The one filesystem WRITE of the lane file surface, kept * out of git-ops.js on purpose: that module only ever touches paths `git * status` reports as changed, and an editor may save any file. * * The guards are the READ path's own (git-diff.js), so the editor can never * write where the viewer could not read: a lexical `lanePath`, a realpath * confined to the lane (no `..`, no symlink out), and no `settings.local.json` * — where a lane's proxy key once lived — at any depth or through any symlink. On top: * only an EXISTING, textual file within the viewer's size cap, and only over * the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save * never silently overwrites what the agent wrote in the meantime (ESTALE). * The file keeps its CRLF line endings and its final newline. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const { contentSha, isSecretOnDisk, lanePath, looksBinary, resolveInLane, MAX_FILE_VIEW_BYTES, } = require("./git-diff"); function bad(code, message) { return Object.assign(new Error(message), { code }); } /** * Overwrite `requested` in lane `dir` with `content`. Throws EBADPATH, * ESECRET, ENOFILE, EBADCONTENT, ETOOBIG, EBINARY or ESTALE. */ async function writeLaneFile(dir, requested, content, baseSha) { const file = lanePath(requested); if (await isSecretOnDisk(dir, file)) { throw bad("ESECRET", "that file holds secrets and is not writable here"); } if (typeof content !== "string") throw bad("EBADCONTENT", "content must be a string"); if (typeof baseSha !== "string" || !/^[0-9a-f]{40}$/.test(baseSha)) { throw bad("EBADCONTENT", "baseSha is required"); } if (Buffer.byteLength(content) > MAX_FILE_VIEW_BYTES) { throw bad("ETOOBIG", "file is too large to edit here"); } const abs = await resolveInLane(dir, file); let current; try { const stat = await fs.promises.stat(abs); if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree"); if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here"); current = await require("./run-as").readFileSafeAsync(abs); } catch (err) { if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err; throw bad("ENOFILE", "no such file in the working tree"); } if (looksBinary(current)) throw bad("EBINARY", "binary files are not editable here"); if (contentSha(current) !== baseSha) { throw bad("ESTALE", "the file changed on disk since it was opened — reload it first"); } // A browser textarea hands back LF only, and the viewer drops the final // newline: put back what the file had, so a one-word fix is a one-line diff. const text = current.toString("utf8"); const eol = text.includes("\r\n") ? "\r\n" : "\n"; let out = content.replace(/\r\n/g, "\n"); if (eol === "\r\n") out = out.replace(/\n/g, "\r\n"); if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol; // As the folder's member under S3 (the dashboard reads, never writes, there). require("./run-as").writeFileAs(abs, out); return { path: file }; } module.exports = { writeLaneFile };