SPB Git

Repository Public

paths.ts 2.7 KB 55 lines typescript

1/**2 * Server-side path validation. The runtime re-validates everything (realpath, symlinks); this3 * layer only guarantees that what we send is a clean relative path under a configured root.4 */5import path from 'node:path';6import type { ServerConfig, WorkspaceRoot } from './config.js';7import { HttpError } from './http.js';8import { RuntimeError } from './runtime-client.js';910/** Normalises an untrusted relative path. Rejects NUL, absolute paths and `..` segments. */11export function validateRel(rel: string, opts: { allowEmpty?: boolean } = {}): string {12  const bad = (why: string): HttpError => new HttpError(400, 'invalid_path', `invalid path: ${why}`);13  if (typeof rel !== 'string' || rel.length > 1024) throw bad('too long');14  if (rel.includes('\0')) throw bad('NUL byte');15  if (rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(rel) || rel.startsWith('~')) throw bad('must be relative');16  const out: string[] = [];17  for (const seg of rel.split('/')) {18    if (seg === '' || seg === '.') continue;19    if (seg === '..') throw bad('".." is not allowed');20    out.push(seg);21  }22  if (out.length === 0 && opts.allowEmpty === false) throw bad('empty');23  return out.join('/');24}2526export function rootByLabel(config: ServerConfig, label?: string | null): WorkspaceRoot {27  const root = label ? config.workspaceRoots.find((r) => r.label === label) : config.workspaceRoots[0];28  if (!root) throw new HttpError(400, 'unknown_root', 'unknown workspace root');29  return root;30}3132/** Absolute directory of `<root>/<rel>`; `rel` must already be validated. */33export function absUnder(rootPath: string, rel: string): string {34  const abs = rel ? path.join(rootPath, rel) : rootPath;35  if (abs !== rootPath && !abs.startsWith(rootPath + path.sep)) throw new HttpError(400, 'invalid_path', 'invalid path');36  return abs;37}3839export function slugify(name: string): string {40  const s = name.normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').toLowerCase().slice(0, 60);41  return s || 'project';42}4344/** Maps a runtime failure onto the browser API error space. */45export function runtimeToHttp(err: unknown): never {46  if (err instanceof RuntimeError) {47    if (err.unreachable) throw new HttpError(503, 'runtime_unavailable', 'the runtime daemon is not reachable');48    // Client-attributable runtime answers keep their status, code and (when present) details,49    // e.g. 409 `conflict` + `{sha256, mtimeMs}`, 409 `exists`, 403 `path_outside_root`, 507 `no_space`.50    if ([400, 403, 404, 409, 413, 415, 422, 429, 507].includes(err.status)) throw new HttpError(err.status, err.code, err.message, err.details);51    throw new HttpError(502, 'runtime_error', 'the runtime daemon reported an error');52  }53  throw err;54}55
Files main
Menu

Theme