SPB Git

Repository Public

fs.ts 12.0 KB 245 lines typescript

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
Files main
Menu

Theme