Repository Public
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