import type { FastifyInstance } from 'fastify'; import { readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import type { FsEntry, FsListResponse } from '@arboretum/shared'; /** * Navigation du système de fichiers pour le sélecteur de dossier côté web. * Ne renvoie QUE des noms de sous-dossiers (jamais de contenu de fichier) ; authentifié * par le hook preValidation global comme tout /api/**. Un utilisateur authentifié dispose * déjà d'un terminal (RCE par conception) : lister des dossiers n'élargit pas le modèle de menace. * * GET /api/v1/fs/list?path=&markRepos=1&showHidden=1 * - path absent → home de l'utilisateur * - markRepos → annote les sous-dossiers qui sont des dépôts git (présence d'un `.git`) * - showHidden → inclut les dotfiles (masqués par défaut) */ export function registerFsRoutes(app: FastifyInstance): void { app.get('/api/v1/fs/list', async (req, reply) => { const q = req.query as { path?: string; markRepos?: string; showHidden?: string; includeFiles?: string }; const raw = q.path && q.path.length > 0 ? q.path : homedir(); if (!raw.startsWith('/')) { return reply.status(400).send({ error: { code: 'BAD_PATH', message: 'path must be absolute' } }); } // resolve() normalise et clampe à la racine : aucun échappement d'arborescence via `..`. const abs = resolve(raw); let st; try { st = await stat(abs); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${abs}` } }); if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } }); return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } }); } if (!st.isDirectory()) { return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${abs}` } }); } const markRepos = q.markRepos === '1' || q.markRepos === 'true'; const showHidden = q.showHidden === '1' || q.showHidden === 'true'; const includeFiles = q.includeFiles === '1' || q.includeFiles === 'true'; let dirents; try { dirents = await readdir(abs, { withFileTypes: true }); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } }); return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } }); } const entries: FsEntry[] = []; for (const d of dirents) { if (!showHidden && d.name.startsWith('.')) continue; let isDir = d.isDirectory(); // symlink éventuel vers un dossier : résolu par stat (tolérant aux liens cassés). if (!isDir && d.isSymbolicLink()) { try { isDir = (await stat(join(abs, d.name))).isDirectory(); } catch { isDir = false; } } const full = join(abs, d.name); if (!isDir) { // En mode includeFiles, on remonte aussi les fichiers (arbre de l'IDE worktree). if (!includeFiles) continue; entries.push({ name: d.name, path: full, isFile: true }); continue; } const entry: FsEntry = { name: d.name, path: full }; if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true; entries.push(entry); } // Dossiers d'abord, puis fichiers ; tri insensible à la casse dans chaque groupe. entries.sort((a, b) => { const af = a.isFile ? 1 : 0; const bf = b.isFile ? 1 : 0; if (af !== bf) return af - bf; return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); const res: FsListResponse = { path: abs, parent: abs === '/' ? null : dirname(abs), home: homedir(), entries, }; return reply.send(res); }); }