/** * @file Downloads one file from a workspace folder, and uploads one file into * it, for the Files tab (issue #78: a member on a server-mode install has no * SSH to the host, so the dashboard is their only way to move a file in or * out). Both work in a folder that is not a git repo too. * * The guards are the READ path's own (git-diff.js): a lexical `lanePath`, a * realpath confined to the workspace (no `..`, no symlink out), and no * `settings.local.json` or member dot-dir entry, by name or by what it * resolves to. A download is opened through `openSafe` (a member's file: a * regular file they own) and streamed, up to DOWNLOAD_MAX_BYTES. * * An upload only CREATES: it refuses a name that already exists (EEXISTS), so * it can never replace a file an agent wrote — the editor's `baseSha` rule * for the one write that does replace. It writes into an existing folder of * the workspace (never makes one), never into or under `.git` (a hook there * runs the next time the dashboard runs git), at most UPLOAD_MAX_BYTES, and — * under S3 — as the folder's member through `write-as` (the host never writes * inside a member's home itself); elsewhere with an exclusive create, which * also refuses to follow a symlink planted at the name. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { isSecretOnDisk, lanePath, laneRoot, resolveInLane } = require("./git-diff"); /** The largest file one upload may carry (it rides one request body). */ const UPLOAD_MAX_BYTES = 25 * 1024 * 1024; /** The largest file one download streams. */ const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024; function bad(code, message) { return Object.assign(new Error(message), { code }); } const underGit = (rel) => rel.split("/").includes(".git"); /** * Open `requested` in workspace `dir` for a download. Returns its base name, * size and a read stream. Throws EBADPATH, ESECRET, ENOFILE or ETOOBIG. */ async function openDownload(dir, requested) { const file = lanePath(requested); if (await isSecretOnDisk(dir, file)) { throw bad("ESECRET", "that file holds secrets and is not downloadable here"); } const abs = await resolveInLane(dir, file); const runAs = require("./run-as"); let fh; try { fh = await runAs.openSafeAsync(abs); } catch { throw bad("ENOFILE", "no such file in the working tree"); } const st = await fh.stat(); if (!st.isFile()) { await fh.close(); throw bad("ENOFILE", "no such file in the working tree"); } if (st.size > DOWNLOAD_MAX_BYTES) { await fh.close(); throw bad("ETOOBIG", "file is larger than 512 MB"); } // Never past the size fstat gave: a file growing under the read is cut. const stream = fh.createReadStream({ start: 0, end: Math.max(0, st.size - 1) }); return { name: path.basename(file), size: st.size, stream }; } /** A file name an upload may create: one path segment, nothing special. */ function uploadName(name) { const n = typeof name === "string" ? name.trim() : ""; if ( !n || n === "." || n === ".." || n === ".git" || /[/\\\0]/.test(n) || Buffer.byteLength(n) > 255 ) { throw bad("EBADNAME", "the file name must be a single, plain file name"); } return n; } /** * Create `name` in the folder `folder` ("" = the workspace root) of workspace * `dir` with `content`. Returns its workspace-relative path. Throws EBADPATH, * EBADNAME, ESECRET, ENOFILE (no such folder), EEXISTS or ETOOBIG. */ async function saveUpload(dir, folder, name, content) { const base = uploadName(name); const sub = folder ? lanePath(folder) : ""; const rel = sub ? `${sub}/${base}` : base; if (underGit(rel)) throw bad("EBADPATH", "nothing is uploaded into .git"); if (!Buffer.isBuffer(content)) throw bad("EBADCONTENT", "the upload carried no file"); if (content.length > UPLOAD_MAX_BYTES) throw bad("ETOOBIG", "a file may be at most 25 MB"); if (await isSecretOnDisk(dir, rel)) { throw bad("ESECRET", "that name is reserved for secrets and is not writable here"); } // The folder must exist inside the workspace, by its REAL path. const root = await laneRoot(dir); let target; if (sub) { let real; try { real = await resolveInLane(dir, sub); } catch (err) { throw err.code === "ENOFILE" ? bad("ENOFILE", "no such folder in the workspace") : err; } const st = await fs.promises.stat(real).catch(() => null); if (!st?.isDirectory()) throw bad("ENOFILE", "no such folder in the workspace"); target = path.join(real, base); } else { target = path.join(root, base); } // Re-checked on the resolved folder: a folder linked to a secret's place. if (await isSecretOnDisk(root, path.relative(root, target))) { throw bad("ESECRET", "that name is reserved for secrets and is not writable here"); } const exists = () => bad("EEXISTS", `${rel} already exists — rename the file or remove that one`); const taken = await fs.promises.lstat(target).then( () => true, () => false ); if (taken) throw exists(); const runAs = require("./run-as"); const acct = runAs.accountForPath(target); if (acct) { // As the member (S3): write-as's `replace` is an atomic new file. The // existence check above is the member's own race to lose, in their home. runAs.writeAs(acct, target, "replace", content); } else { try { // `wx`: O_CREAT|O_EXCL — fails on any existing name, a symlink included. await fs.promises.writeFile(target, content, { flag: "wx" }); } catch (err) { if (err.code === "EEXIST") throw exists(); throw err; } } return { path: rel }; } module.exports = { openDownload, saveUpload, UPLOAD_MAX_BYTES, DOWNLOAD_MAX_BYTES };