SPB Git

Repository Public

paths.ts 3.5 KB 80 lines typescript

1/**2 * Path safety for projects under `AOS_WORKSPACE_ROOT` (same rules as the file tools, CONTRACT §4):3 * clean relative path, no `..`, no NUL, no absolute path, and the realpath of the deepest4 * existing ancestor must stay inside the realpath of the root (symlink escapes are refused).5 */6import fs from 'node:fs';7import path from 'node:path';8import { HttpError } from './http.js';910const bad = (why: string): HttpError => new HttpError(400, 'invalid_path', `invalid path: ${why}`);1112/** Normalises an untrusted relative path. Rejects NUL, absolute paths and `..` segments. */13export function validateRel(rel: string, opts: { allowEmpty?: boolean } = {}): string {14  if (typeof rel !== 'string' || rel.length > 1024) throw bad('too long');15  if (rel.includes('\0')) throw bad('NUL byte');16  if (rel.startsWith('/') || rel.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(rel) || rel.startsWith('~')) throw bad('must be relative');17  const out: string[] = [];18  for (const seg of rel.split('/')) {19    if (seg === '' || seg === '.') continue;20    if (seg === '..') throw bad('".." is not allowed');21    out.push(seg);22  }23  if (out.length === 0 && opts.allowEmpty === false) throw bad('empty');24  return out.join('/');25}2627/** A project directory: a non-empty clean relative path without control characters or protected segments. */28export function validateProjectRel(rel: string): string {29  const clean = validateRel(rel, { allowEmpty: false });30  // eslint-disable-next-line no-control-regex31  if (/[\x00-\x1f\x7f\\]/.test(clean)) throw bad('control characters or backslashes');32  for (const seg of clean.split('/')) {33    if (seg === '.git' || seg === '.aos') throw bad(`"${seg}" is a protected directory`);34    if (seg.startsWith('-')) throw bad('a segment may not start with "-"');35  }36  return clean;37}3839/**40 * Absolute path of `<root>/<rel>` after symlink resolution of what already exists. Throws 40041 * `path_outside_root` when any existing component resolves outside the root. `rel` must already42 * be validated. The returned path is `realpath(deepest existing ancestor)` + the missing tail.43 */44export function resolveInRoot(root: string, rel: string): { abs: string; exists: boolean } {45  let realRoot: string;46  try {47    realRoot = fs.realpathSync(root);48  } catch {49    throw new HttpError(503, 'workspace_root_missing', 'the workspace root does not exist on this host');50  }51  const segs = rel.split('/').filter(Boolean);52  let cur = realRoot;53  let i = 0;54  for (; i < segs.length; i++) {55    const next = path.join(cur, segs[i] as string);56    let real: string;57    try {58      real = fs.realpathSync(next);59    } catch (err) {60      if ((err as NodeJS.ErrnoException).code === 'ENOENT') {61        // A dangling symlink is not "missing": refuse instead of writing through it later.62        if (fs.lstatSync(next, { throwIfNoEntry: false })) throw new HttpError(400, 'path_outside_root', 'path escapes the workspace root');63        break;64      }65      throw bad('not accessible');66    }67    if (real !== realRoot && !real.startsWith(realRoot + path.sep)) throw new HttpError(400, 'path_outside_root', 'path escapes the workspace root');68    cur = real;69  }70  const exists = i === segs.length;71  const abs = exists ? cur : path.join(cur, ...segs.slice(i));72  if (abs === realRoot) throw bad('the workspace root itself is not a project');73  return { abs, exists };74}7576export function slugify(name: string): string {77  const s = name.normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/^-+/, '').toLowerCase().slice(0, 60);78  return s || 'project';79}80
Files main
Menu

Theme