Repository Public
1/**2 * Filesystem IPC (runtime-fs): /fs/list, /fs/stat, /fs/read, /fs/write, /fs/op, plus3 * /fs/upload + /fs/download (fsx-transfer.ts) and /fs/search (fsx-search.ts).4 * Every path goes through `resolveInside`; responses only carry project-relative POSIX paths.5 * File contents and names are never logged.6 */7import { createHash, randomBytes } from 'node:crypto';8import { promises as fsp } from 'node:fs';9import type { Stats } from 'node:fs';10import path from 'node:path';11import { z } from 'zod';12import type { FileContent, FileEntry } from '@cr/shared';13import type { RuntimeConfig } from './config.js';14import { RtError, type RuntimePlugin } from './plugins.js';15import { resolveInsideEx, toRel } from './paths.js';16import { registerTransfer } from './fsx-transfer.js';17import { registerSearch, type SearchOptions } from './fsx-search.js';1819export const MAX_READ_BYTES = 2 * 1024 * 1024;20export const MAX_WRITE_BYTES = 5 * 1024 * 1024;21export const MAX_LIST_ENTRIES = 5000;22const SNIFF_BYTES = 8192;2324const RootRel = z.object({ root: z.string().min(1), rel: z.string().default('') });25const WriteBody = RootRel.extend({26 content: z.string(),27 baseSha256: z.string().regex(/^[a-f0-9]{64}$/i).optional(),28 create: z.boolean().optional(),29});30const OpBody = z.discriminatedUnion('op', [31 z.object({ root: z.string().min(1), op: z.literal('mkdir'), path: z.string() }),32 z.object({ root: z.string().min(1), op: z.literal('rename'), from: z.string(), to: z.string() }),33 z.object({ root: z.string().min(1), op: z.literal('delete'), path: z.string(), confirm: z.literal(true) }),34]);3536export function parseInput<S extends z.ZodType>(schema: S, input: unknown): z.infer<S> {37 const r = schema.safeParse(input);38 if (!r.success) {39 const first = r.error.issues[0];40 throw new RtError(400, 'bad_request', `invalid ${first?.path.join('.') || 'input'}: ${first?.message ?? 'invalid'}`);41 }42 return r.data;43}4445export function entryType(st: Stats): FileEntry['type'] {46 if (st.isSymbolicLink()) return 'symlink';47 if (st.isDirectory()) return 'dir';48 if (st.isFile()) return 'file';49 return 'other';50}51function toEntry(name: string, rel: string, st: Stats): FileEntry {52 return { name, path: rel, type: entryType(st), size: st.isDirectory() ? 0 : st.size, mtimeMs: Math.round(st.mtimeMs) };53}54export function sha256(buf: Buffer | string): string {55 return createHash('sha256').update(buf).digest('hex');56}57export function mapFsError(e: unknown): never {58 if (e instanceof RtError) throw e;59 const code = (e as NodeJS.ErrnoException | null)?.code;60 if (code === 'ENOENT') throw new RtError(404, 'not_found', 'path does not exist');61 if (code === 'ENOTDIR') throw new RtError(400, 'not_a_directory', 'path is not a directory');62 if (code === 'EISDIR') throw new RtError(400, 'is_directory', 'path is a directory');63 if (code === 'EACCES' || code === 'EPERM') throw new RtError(403, 'forbidden', 'permission denied');64 if (code === 'EEXIST' || code === 'ENOTEMPTY') throw new RtError(409, 'exists', 'destination already exists');65 if (code === 'ENOSPC') throw new RtError(507, 'no_space', 'no space left on device');66 if (code === 'ENAMETOOLONG') throw new RtError(400, 'bad_path', 'path is too long');67 throw new RtError(500, 'fs_error', `filesystem error${code ? ` (${code})` : ''}`);68}6970async function listDir(config: RuntimeConfig, root: string, rel: string): Promise<{ entries: FileEntry[]; truncated: boolean }> {71 const dir = await resolveInsideEx(config, root, rel);72 if (!dir.exists) throw new RtError(404, 'not_found', 'directory does not exist');73 if (!dir.stats?.isDirectory()) throw new RtError(400, 'not_a_directory', 'path is not a directory');74 const names: string[] = [];75 let truncated = false;76 const handle = await fsp.opendir(dir.abs).catch(mapFsError);77 try {78 for await (const d of handle) {79 if (names.length >= MAX_LIST_ENTRIES) {80 truncated = true;81 break;82 }83 names.push(d.name);84 }85 } catch (e) {86 mapFsError(e);87 }88 const entries: FileEntry[] = [];89 for (let i = 0; i < names.length; i += 64) {90 const chunk = names.slice(i, i + 64);91 const stats = await Promise.all(chunk.map((n) => fsp.lstat(path.join(dir.abs, n)).catch(() => null)));92 chunk.forEach((name, j) => {93 const st = stats[j];94 // lstat only: a symlink is reported as such and never followed.95 if (st) entries.push(toEntry(name, dir.rel ? `${dir.rel}/${name}` : name, st));96 });97 }98 entries.sort((a, b) => {99 const da = a.type === 'dir' ? 0 : 1;100 const db = b.type === 'dir' ? 0 : 1;101 if (da !== db) return da - db;102 const la = a.name.toLowerCase();103 const lb = b.name.toLowerCase();104 return la < lb ? -1 : la > lb ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0;105 });106 return { entries, truncated };107}108109async function readFile(config: RuntimeConfig, root: string, rel: string): Promise<FileContent> {110 const r = await resolveInsideEx(config, root, rel);111 if (!r.exists || !r.stats) throw new RtError(404, 'not_found', 'file does not exist');112 if (r.stats.isDirectory()) throw new RtError(400, 'is_directory', 'path is a directory');113 if (!r.stats.isFile()) throw new RtError(400, 'special_file', 'not a regular file');114 if (r.stats.size > MAX_READ_BYTES) throw new RtError(413, 'too_large', `file is larger than ${MAX_READ_BYTES} bytes`);115 const buf = await fsp.readFile(r.abs).catch(mapFsError);116 if (buf.length > MAX_READ_BYTES) throw new RtError(413, 'too_large', `file is larger than ${MAX_READ_BYTES} bytes`);117 if (buf.subarray(0, SNIFF_BYTES).includes(0)) throw new RtError(415, 'binary', 'binary file');118 return { path: r.rel, size: buf.length, mtimeMs: Math.round(r.stats.mtimeMs), sha256: sha256(buf), encoding: 'utf8', content: buf.toString('utf8') };119}120121/** tmp file in the same directory + fsync + rename; the temp never survives a failure. */122export async function atomicWrite(abs: string, data: Buffer, mode: number): Promise<void> {123 const dir = path.dirname(abs);124 const tmp = path.join(dir, `.${path.basename(abs).slice(0, 100)}.cr-tmp-${randomBytes(6).toString('hex')}`);125 let handle: fsp.FileHandle | null = null;126 try {127 handle = await fsp.open(tmp, 'wx', mode);128 await handle.writeFile(data);129 await handle.chmod(mode); // open() mode is subject to umask130 await handle.sync();131 await handle.close();132 handle = null;133 await fsp.rename(tmp, abs);134 } catch (e) {135 await handle?.close().catch(() => undefined);136 await fsp.rm(tmp, { force: true }).catch(() => undefined);137 throw e;138 }139 // Best-effort directory fsync so the rename itself is durable.140 const dh = await fsp.open(dir, 'r').catch(() => null);141 if (dh) {142 await dh.sync().catch(() => undefined);143 await dh.close().catch(() => undefined);144 }145}146147type WriteMeta = Omit<FileContent, 'content'>;148interface ConflictBody {149 error: { code: 'conflict'; message: string; details: { sha256: string | null; mtimeMs: number | null } };150}151152async function writeFile(config: RuntimeConfig, body: z.infer<typeof WriteBody>): Promise<WriteMeta | ConflictBody> {153 const data = Buffer.from(body.content, 'utf8');154 if (data.length > MAX_WRITE_BYTES) throw new RtError(413, 'too_large', `content is larger than ${MAX_WRITE_BYTES} bytes`);155 const r = await resolveInsideEx(config, body.root, body.rel, { forWrite: true });156 if (r.abs === r.rootReal) throw new RtError(400, 'is_directory', 'path is the project root');157 let mode = 0o644;158 if (r.exists && r.stats) {159 if (r.stats.isDirectory()) throw new RtError(400, 'is_directory', 'path is a directory');160 if (!r.stats.isFile()) throw new RtError(400, 'special_file', 'not a regular file');161 if (body.create) throw new RtError(409, 'exists', 'file already exists');162 mode = r.stats.mode & 0o7777;163 if (body.baseSha256) {164 const current = sha256(await fsp.readFile(r.abs).catch(mapFsError));165 if (current !== body.baseSha256.toLowerCase()) {166 return {167 error: { code: 'conflict', message: 'file changed on disk since it was loaded', details: { sha256: current, mtimeMs: Math.round(r.stats.mtimeMs) } },168 };169 }170 }171 } else {172 if (body.baseSha256) {173 return { error: { code: 'conflict', message: 'file no longer exists on disk', details: { sha256: null, mtimeMs: null } } };174 }175 const parent = await fsp.stat(path.dirname(r.abs)).catch(() => null);176 if (!parent?.isDirectory()) throw new RtError(404, 'parent_not_found', 'parent directory does not exist');177 }178 await atomicWrite(r.abs, data, mode).catch(mapFsError);179 const st = await fsp.stat(r.abs).catch(mapFsError);180 return { path: r.rel, size: st.size, mtimeMs: Math.round(st.mtimeMs), sha256: sha256(data), encoding: 'utf8' };181}182183async function fsOp(config: RuntimeConfig, body: z.infer<typeof OpBody>): Promise<{ ok: true; path: string }> {184 if (body.op === 'mkdir') {185 const r = await resolveInsideEx(config, body.root, body.path, { forWrite: true });186 if (r.exists) {187 if (r.stats?.isDirectory()) return { ok: true, path: r.rel };188 throw new RtError(409, 'exists', 'a file with this name already exists');189 }190 await fsp.mkdir(r.abs, { recursive: true }).catch(mapFsError);191 return { ok: true, path: r.rel };192 }193 if (body.op === 'rename') {194 const from = await resolveInsideEx(config, body.root, body.from, { noFollowFinal: true });195 if (from.abs === from.rootReal) throw new RtError(400, 'root_refused', 'the project root cannot be renamed');196 if (!from.exists) throw new RtError(404, 'not_found', 'source does not exist');197 const to = await resolveInsideEx(config, body.root, body.to, { forWrite: true, noFollowFinal: true });198 if (to.abs === to.rootReal) throw new RtError(400, 'root_refused', 'destination is the project root');199 if (to.exists) throw new RtError(409, 'exists', 'destination already exists');200 if (to.abs.startsWith(from.abs + path.sep)) throw new RtError(400, 'bad_request', 'cannot move a directory into itself');201 const parent = await fsp.stat(path.dirname(to.abs)).catch(() => null);202 if (!parent?.isDirectory()) throw new RtError(404, 'parent_not_found', 'destination directory does not exist');203 await fsp.rename(from.abs, to.abs).catch(mapFsError);204 return { ok: true, path: toRel(to.rootReal, to.abs) };205 }206 const r = await resolveInsideEx(config, body.root, body.path, { noFollowFinal: true });207 if (r.abs === r.rootReal) throw new RtError(400, 'root_refused', 'the project root cannot be deleted');208 if (!r.exists) throw new RtError(404, 'not_found', 'path does not exist');209 // fs.rm unlinks symlinks; it never descends into their targets.210 await fsp.rm(r.abs, { recursive: true, force: false }).catch(mapFsError);211 return { ok: true, path: r.rel };212}213214export function createFsPlugin(search: SearchOptions = {}): RuntimePlugin {215 return async (app, { config }) => {216 app.post('/fs/list', async (req, reply) => {217 const { root, rel } = parseInput(RootRel, req.body);218 const { entries, truncated } = await listDir(config, root, rel);219 if (truncated) void reply.header('X-Truncated', '1');220 return entries;221 });222 app.post('/fs/stat', async (req) => {223 const { root, rel } = parseInput(RootRel, req.body);224 const r = await resolveInsideEx(config, root, rel, { noFollowFinal: true });225 if (!r.exists || !r.stats) throw new RtError(404, 'not_found', 'path does not exist');226 return toEntry(r.abs === r.rootReal ? '' : path.basename(r.abs), r.rel, r.stats);227 });228 app.post('/fs/read', async (req) => {229 const { root, rel } = parseInput(RootRel, req.body);230 return readFile(config, root, rel);231 });232 app.post('/fs/write', { bodyLimit: MAX_WRITE_BYTES * 6 + 65536 }, async (req, reply) => {233 const result = await writeFile(config, parseInput(WriteBody, req.body));234 // Sent here (not thrown) so `details` reaches the caller whatever the global error handler does.235 if ('error' in result) return reply.code(409).send(result);236 return result;237 });238 app.post('/fs/op', async (req) => fsOp(config, parseInput(OpBody, req.body)));239 await registerTransfer(app, config);240 registerSearch(app, config, search);241 };242}243244export default createFsPlugin();245