// API fichiers (P7) pour l'éditeur Monaco : lecture/écriture du contenu d'un fichier, strictement // bornées à un worktree ENREGISTRÉ (WorktreeManager.assertPathInWorktree : anti `..`, anti symlink // sortant). Authentifiée par le hook preValidation global. Un terminal web est déjà du RCE par // conception, mais on borne quand même (défense en profondeur + moindre étonnement). import type { FastifyInstance } from 'fastify'; import { readFile, writeFile, stat, mkdir } from 'node:fs/promises'; import { dirname, extname } from 'node:path'; import type { FileContentResponse, WriteFileRequest, WriteFileResponse } from '@arboretum/shared'; import type { WorktreeManager } from '../core/worktree-manager.js'; import type { Db } from '../db/index.js'; import { recordAudit } from '../core/audit-log.js'; import { sendManagerError } from './repos.js'; const FILE_MAX_BYTES = 2 * 1024 * 1024; /** Langage Monaco déduit de l'extension (sous-ensemble courant ; Monaco a un fallback 'plaintext'). */ const LANG_BY_EXT: Record = { '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.json': 'json', '.vue': 'vue', '.css': 'css', '.scss': 'scss', '.less': 'less', '.html': 'html', '.md': 'markdown', '.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java', '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cs': 'csharp', '.php': 'php', '.sh': 'shell', '.bash': 'shell', '.zsh': 'shell', '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'toml', '.xml': 'xml', '.sql': 'sql', '.swift': 'swift', '.kt': 'kotlin', '.dart': 'dart', '.lua': 'lua', }; function languageFor(path: string): string | undefined { return LANG_BY_EXT[extname(path).toLowerCase()]; } /** Détecte un fichier binaire par présence d'un octet NUL dans le buffer. */ function looksBinary(buf: Buffer): boolean { const n = Math.min(buf.length, 8192); for (let i = 0; i < n; i++) if (buf[i] === 0) return true; return false; } export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void { // Lecture du contenu d'un fichier (UTF-8) d'un worktree. app.get('/api/v1/repos/:id/files/content', async (req, reply) => { const { id } = req.params as { id: string }; const q = req.query as { wt?: string; path?: string }; if (typeof q.wt !== 'string' || q.wt === '' || typeof q.path !== 'string' || q.path === '') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt and path are required' } }); } try { const abs = await wt.assertPathInWorktree(id, q.wt, q.path); const st = await stat(abs).catch(() => null); if (!st || !st.isFile()) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No such file' } }); if (st.size > FILE_MAX_BYTES) return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `File exceeds ${FILE_MAX_BYTES} bytes` } }); const buf = await readFile(abs); if (looksBinary(buf)) return reply.status(415).send({ error: { code: 'BINARY_FILE', message: 'Binary files are not editable' } }); const lang = languageFor(q.path); const res: FileContentResponse = { path: q.path, content: buf.toString('utf-8'), encoding: 'utf-8', size: st.size, mtime: st.mtimeMs, ...(lang ? { language: lang } : {}), }; return reply.send(res); } catch (err) { return sendManagerError(reply, err); } }); // Écriture (création/modification) du contenu d'un fichier d'un worktree. app.put('/api/v1/repos/:id/files/content', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.wt !== 'string' || typeof body.path !== 'string' || typeof body.content !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt, path and content are required' } }); } if (Buffer.byteLength(body.content, 'utf-8') > FILE_MAX_BYTES) { return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `Content exceeds ${FILE_MAX_BYTES} bytes` } }); } try { const abs = await wt.assertPathInWorktree(id, body.wt, body.path); // Garde-fou conflit (P8) : si le client a chargé le fichier (baseMtime) et qu'il a changé // depuis (ex. Claude a écrit en parallèle), on refuse pour ne pas écraser silencieusement. if (typeof body.baseMtime === 'number') { const cur = await stat(abs).catch(() => null); if (cur && cur.isFile() && cur.mtimeMs !== body.baseMtime) { return reply.status(409).send({ error: { code: 'STALE_FILE', message: 'File changed on disk since it was loaded' } }); } } await mkdir(dirname(abs), { recursive: true }); // dirname reste sous le worktree (abs validé) await writeFile(abs, body.content, 'utf-8'); const after = await stat(abs); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'file.write', resourceId: id, details: { path: body.path } }); const res: WriteFileResponse = { ok: true, size: Buffer.byteLength(body.content, 'utf-8'), mtime: after.mtimeMs }; return reply.send(res); } catch (err) { return sendManagerError(reply, err); } }); }