feat(web): sélecteur de répertoire (parcourir au lieu de taper le chemin)

Ajoute un navigateur de dossiers réutilisable sur les deux champs qui
exigeaient un chemin absolu tapé à la main : « Répertoire de travail »
(nouvelle session) et « Chemin du dépôt » (ajout de repo). Un bouton
« Parcourir… » déplie un panneau inline ; le champ texte reste pour
taper/coller un chemin connu. Pour l'ajout de repo, les sous-dossiers
qui sont des dépôts git sont signalés par un badge.

Serveur : nouvel endpoint GET /api/v1/fs/list (authentifié par le hook
preValidation global), ne renvoie que des noms de sous-dossiers (jamais
de contenu de fichier). Valide le chemin via resolve() (clampe à la
racine, neutralise « .. »), masque les dotfiles par défaut (showHidden),
annote les dépôts git (markRepos), tolère les entrées illisibles.

Tests : packages/server/test/fs-routes.test.ts (10 cas — listing, tri,
hidden, markRepos, rejets 400/404, auth 401). Suite : 185/185 verts.
This commit is contained in:
2026-06-17 13:04:10 +02:00
parent a33c5d0904
commit 3acf27d48f
9 changed files with 378 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ import { registerSessionRoutes } from './routes/sessions.js';
import { registerRepoRoutes } from './routes/repos.js';
import { registerWorktreeRoutes } from './routes/worktrees.js';
import { registerPushRoutes } from './routes/push.js';
import { registerFsRoutes } from './routes/fs.js';
import { registerWsGateway } from './ws/gateway.js';
declare module 'fastify' {
@@ -92,6 +93,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerRepoRoutes(app, worktrees);
registerWorktreeRoutes(app, worktrees);
registerPushRoutes(app, push);
registerFsRoutes(app);
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
void app.register(async (scoped) => {

View File

@@ -0,0 +1,82 @@
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=<abs>&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 };
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';
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;
}
}
if (!isDir) continue;
const full = join(abs, d.name);
const entry: FsEntry = { name: d.name, path: full };
if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true;
entries.push(entry);
}
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
const res: FsListResponse = {
path: abs,
parent: abs === '/' ? null : dirname(abs),
home: homedir(),
entries,
};
return reply.send(res);
});
}

View File

@@ -0,0 +1,143 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildApp, type AppBundle } from '../src/app.js';
import { openDb, type Db } from '../src/db/index.js';
import type { Config } from '../src/config.js';
import type { FsListResponse } from '@arboretum/shared';
// Mêmes stubs que app.e2e : pas de vrai claude ni de vrai PTY en CI.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
class FakePty {
pid = 424242;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
onData(): { dispose: () => void } {
return { dispose: () => {} };
}
onExit(): { dispose: () => void } {
return { dispose: () => {} };
}
}
return { default: { spawn: (): FakePty => new FakePty() } };
});
process.env.ARBORETUM_LOG = 'silent';
let dir: string;
let root: string;
let bundle: AppBundle;
let db: Db;
let token: string;
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'arboretum-fs-'));
// Arbo de test : root/{alpha, Beta, .hidden, a-repo/.git, file.txt}
root = join(dir, 'root');
mkdirSync(join(root, 'alpha'), { recursive: true });
mkdirSync(join(root, 'Beta'), { recursive: true });
mkdirSync(join(root, '.hidden'), { recursive: true });
mkdirSync(join(root, 'a-repo', '.git'), { recursive: true });
writeFileSync(join(root, 'file.txt'), 'x');
const dbPath = join(dir, 'fs.db');
db = openDb(dbPath);
const config: Config = {
port: 7317,
bind: '127.0.0.1',
dbPath,
dataDir: dir,
allowedOrigins: [],
printToken: false,
claudeProjectsDir: join(dir, 'claude', 'projects'),
claudeSessionsDir: join(dir, 'claude', 'sessions'),
vapidContact: 'mailto:test@localhost',
};
bundle = buildApp(config, db, '0.0.0-test');
const t = bundle.auth.ensureBootstrapToken();
if (!t) throw new Error('bootstrap token attendu sur une base vierge');
token = t;
});
afterAll(async () => {
await bundle.app.close();
db.close();
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/v1/fs/list', () => {
it('liste les sous-dossiers triés, masque les dotfiles et les fichiers', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() });
expect(res.statusCode).toBe(200);
const body = res.json() as FsListResponse;
const names = body.entries.map((e) => e.name);
expect(names).toEqual(['a-repo', 'alpha', 'Beta']); // tri insensible à la casse, fichiers/dotfiles exclus
expect(body.path).toBe(root);
expect(body.parent).toBe(dir);
expect(typeof body.home).toBe('string');
});
it('showHidden=1 inclut les dossiers cachés', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&showHidden=1`, headers: auth() });
const body = res.json() as FsListResponse;
expect(body.entries.map((e) => e.name)).toContain('.hidden');
});
it('markRepos=1 annote le dossier contenant un .git', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&markRepos=1`, headers: auth() });
const body = res.json() as FsListResponse;
expect(body.entries.find((e) => e.name === 'a-repo')?.isRepo).toBe(true);
expect(body.entries.find((e) => e.name === 'alpha')?.isRepo).toBeUndefined();
});
it('sans markRepos, aucune annotation isRepo', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() });
const body = res.json() as FsListResponse;
expect(body.entries.every((e) => e.isRepo === undefined)).toBe(true);
});
it('path absent → liste le home (réponse 200 cohérente)', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list', headers: auth() });
expect(res.statusCode).toBe(200);
const body = res.json() as FsListResponse;
expect(body.path).toBe(body.home);
});
it('chemin relatif → 400 BAD_PATH', async () => {
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list?path=relatif/x', headers: auth() });
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: { code: 'BAD_PATH' } });
});
it('échappement par `..` neutralisé : resolve clampe, pas de fuite (200 sur un dossier réel)', async () => {
// `<root>/../..` résout vers un ancêtre réel : la requête réussit mais ne contient jamais de `..`.
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, '..', '..'))}`, headers: auth() });
expect(res.statusCode).toBe(200);
expect((res.json() as FsListResponse).path).not.toContain('..');
});
it('chemin inexistant → 404 NOT_FOUND', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'nope-xyz'))}`, headers: auth() });
expect(res.statusCode).toBe(404);
expect(res.json()).toMatchObject({ error: { code: 'NOT_FOUND' } });
});
it('cible non-dossier → 400 NOT_A_DIRECTORY', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'file.txt'))}`, headers: auth() });
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: { code: 'NOT_A_DIRECTORY' } });
});
it('sans authentification → 401', async () => {
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}` });
expect(res.statusCode).toBe(401);
expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } });
});
});