SPB Git

Repository Public

git.ts 10.6 KB 218 lines typescript

1/**2 * Git IPC (runtime-fs): /git/info, /git/status, /git/diff, /git/clone.3 * Every call is `git -C <dir> …` through an argv array. Read commands disable fsmonitor, hooks,4 * external diff drivers and textconv so that *inspecting* a repository never runs code the5 * repository controls. Clone only accepts whitelisted URL forms and never prompts.6 */7import { promises as fsp } from 'node:fs';8import path from 'node:path';9import { z } from 'zod';10import type { GitStatus } from '@cr/shared';11import type { RuntimeConfig } from './config.js';12import { RtError, type RuntimePlugin } from './plugins.js';13import { assertRootAllowed, isInside, resolveInsideEx } from './paths.js';14import { run, type RunResult } from './fsx-exec.js';1516const GIT_TIMEOUT_MS = 15_000;17const CLONE_TIMEOUT_MS = 10 * 60_000;18export const MAX_DIFF_BYTES = 1024 * 1024;19const MAX_STATUS_FILES = 5000;20const SAFE_READ_CONFIG = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null'];2122export interface GitOptions {23  /** Test seam only: replaces the clone URL whitelist. Production uses `validateCloneUrl`. */24  validateUrl?: (url: string) => string | null;25  gitBin?: string;26}2728function gitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {29  const env: NodeJS.ProcessEnv = {};30  for (const [k, v] of Object.entries(process.env)) {31    // Inherited GIT_* could redirect git to another repository or config.32    if (!k.startsWith('GIT_') && v !== undefined) env[k] = v;33  }34  return { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', ...extra };35}3637async function gitRead(bin: string, dir: string, args: string[], maxStdout = 4 * 1024 * 1024): Promise<RunResult> {38  const r = await run(bin, ['-C', dir, ...SAFE_READ_CONFIG, ...args], { env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS, maxStdout });39  if (r.spawnError) throw new RtError(500, 'git_unavailable', 'git could not be started');40  if (r.timedOut) throw new RtError(504, 'git_timeout', 'git did not answer in time');41  return r;42}4344/** The repository is only used when its top level is inside the project root. */45async function repoTop(bin: string, rootReal: string): Promise<string | null> {46  const r = await gitRead(bin, rootReal, ['rev-parse', '--show-toplevel']);47  if (r.code !== 0) return null;48  const top = r.stdout.replace(/\r?\n$/, '');49  if (!top) return null;50  const topReal = await fsp.realpath(top).catch(() => null);51  return topReal && isInside(rootReal, topReal) ? topReal : null;52}5354/** Parse `git status --porcelain=v2 --branch -z`. */55export function parseStatusV2(out: string): GitStatus {56  const status: GitStatus = { isRepo: true, branch: null, ahead: 0, behind: 0, files: [] };57  const records = out.split('\0');58  const pathAfter = (rec: string, fields: number): string => {59    let idx = 0;60    for (let i = 0; i < fields; i++) {61      idx = rec.indexOf(' ', idx) + 1;62      if (idx === 0) return '';63    }64    return rec.slice(idx);65  };66  for (let i = 0; i < records.length; i++) {67    const rec = records[i] as string;68    if (rec === '') continue;69    if (rec.startsWith('# branch.head ')) {70      const head = rec.slice('# branch.head '.length);71      status.branch = head === '(detached)' ? null : head;72    } else if (rec.startsWith('# branch.ab ')) {73      const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(rec);74      if (m) {75        status.ahead = Number(m[1]);76        status.behind = Number(m[2]);77      }78    } else if (rec.startsWith('#')) {79      continue;80    } else if (status.files.length >= MAX_STATUS_FILES) {81      if (rec.startsWith('2 ')) i++;82    } else if (rec.startsWith('1 ')) {83      status.files.push({ path: pathAfter(rec, 8), index: rec[2] ?? '.', worktree: rec[3] ?? '.' });84    } else if (rec.startsWith('2 ')) {85      status.files.push({ path: pathAfter(rec, 9), index: rec[2] ?? '.', worktree: rec[3] ?? '.' });86      i++; // the next NUL-separated record is the original path87    } else if (rec.startsWith('u ')) {88      status.files.push({ path: pathAfter(rec, 10), index: rec[2] ?? 'U', worktree: rec[3] ?? 'U' });89    } else if (rec.startsWith('? ')) {90      status.files.push({ path: rec.slice(2), index: '?', worktree: '?' });91    } else if (rec.startsWith('! ')) {92      status.files.push({ path: rec.slice(2), index: '!', worktree: '!' });93    }94  }95  return status;96}9798/** Remove credentials from a remote URL before it leaves the runtime. */99export function sanitizeRemote(url: string): string {100  return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/i, '$1').replace(/[?#].*$/, '');101}102103/**104 * Clone URL whitelist. Returns null when acceptable, else the reason.105 * Allowed: https://…, ssh://…, git@host:path, gitsrv:path. Never an option, never ext::/file://.106 */107export function validateCloneUrl(url: string): string | null {108  if (url.length === 0 || url.length > 500) return 'URL length is invalid';109  if (/[\s\x00-\x1f\x7f]/.test(url)) return 'URL contains whitespace or control characters';110  if (url.startsWith('-')) return 'URL must not start with "-"';111  if (/^https:\/\//i.test(url) || /^ssh:\/\//i.test(url)) {112    let u: URL;113    try {114      u = new URL(url);115    } catch {116      return 'URL is malformed';117    }118    if (u.protocol !== 'https:' && u.protocol !== 'ssh:') return 'URL scheme is not allowed';119    if (!/^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(u.hostname)) return 'URL host is invalid';120    if (decodeURIComponent(u.username).startsWith('-')) return 'URL user is invalid';121    if (u.pathname.length <= 1) return 'URL has no repository path';122    // The raw authority must match what was parsed (no "ssh://-oProxyCommand=…" style tricks).123    const authority = url.slice(url.indexOf('//') + 2).split('/')[0] ?? '';124    const rawHost = authority.slice(authority.lastIndexOf('@') + 1);125    if (rawHost.startsWith('-') || authority.startsWith('-')) return 'URL host is invalid';126    return null;127  }128  const scp = /^git@([A-Za-z0-9][A-Za-z0-9.-]*):([A-Za-z0-9._~/-]+)$/.exec(url);129  if (scp) return (scp[2] as string).startsWith('-') ? 'repository path is invalid' : null;130  const srv = /^gitsrv:([A-Za-z0-9._~/-]+)$/.exec(url);131  if (srv) return (srv[1] as string).startsWith('-') ? 'repository path is invalid' : null;132  return 'only https://, ssh://, git@host:path and gitsrv:path URLs are allowed';133}134135const RootBody = z.object({ root: z.string().min(1) });136const DiffBody = RootBody.extend({ rel: z.string().optional(), staged: z.boolean().optional() });137const CloneBody = RootBody.extend({ rel: z.string().min(1), url: z.string().min(1).max(500) });138139function parse<S extends z.ZodType>(schema: S, input: unknown): z.infer<S> {140  const r = schema.safeParse(input);141  if (!r.success) throw new RtError(400, 'bad_request', `invalid ${r.error.issues[0]?.path.join('.') || 'body'}`);142  return r.data;143}144145const NOT_REPO: GitStatus = { isRepo: false, branch: null, ahead: 0, behind: 0, files: [] };146147async function status(bin: string, rootReal: string): Promise<GitStatus> {148  if (!(await repoTop(bin, rootReal))) return { ...NOT_REPO };149  const r = await gitRead(bin, rootReal, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal'], 8 * 1024 * 1024);150  if (r.code !== 0) throw new RtError(500, 'git_failed', 'git status failed');151  return parseStatusV2(r.stdout);152}153154async function clone(config: RuntimeConfig, opts: GitOptions, bin: string, body: z.infer<typeof CloneBody>): Promise<{ ok: true; path: string }> {155  const reason = (opts.validateUrl ?? validateCloneUrl)(body.url);156  if (reason) throw new RtError(400, 'bad_url', reason);157  const dest = await resolveInsideEx(config, body.root, body.rel, { forWrite: true, noFollowFinal: true });158  if (dest.abs === dest.rootReal) throw new RtError(409, 'exists', 'destination already exists');159  if (dest.exists) throw new RtError(409, 'exists', 'destination already exists');160  await fsp.mkdir(path.dirname(dest.abs), { recursive: true });161  const env = gitEnv({ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new' });162  const r = await run(bin, ['-c', 'protocol.ext.allow=never', 'clone', '--', body.url, dest.abs], {163    cwd: dest.rootReal,164    env,165    timeoutMs: CLONE_TIMEOUT_MS,166    maxStdout: 64 * 1024,167  });168  if (r.code !== 0 || r.spawnError || r.timedOut) {169    await fsp.rm(dest.abs, { recursive: true, force: true }).catch(() => undefined);170    if (r.spawnError) throw new RtError(500, 'git_unavailable', 'git could not be started');171    if (r.timedOut) throw new RtError(504, 'clone_timeout', 'git clone timed out');172    // Last meaningful stderr line, without credentials or host paths.173    const line = r.stderr.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? 'git clone failed';174    const safe = line.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1').split(dest.rootReal).join('<root>').slice(0, 300);175    throw new RtError(502, 'clone_failed', safe);176  }177  return { ok: true, path: dest.rel };178}179180export function createGitPlugin(opts: GitOptions = {}): RuntimePlugin {181  const bin = opts.gitBin ?? 'git';182  return async (app, { config }) => {183    app.post('/git/info', async (req) => {184      const rootReal = await assertRootAllowed(config, parse(RootBody, req.body).root);185      const st = await status(bin, rootReal);186      if (!st.isRepo) return { isRepo: false, branch: null, remote: null, dirty: null };187      const remote = await gitRead(bin, rootReal, ['config', '--get', 'remote.origin.url']);188      const url = remote.code === 0 ? remote.stdout.trim() : '';189      return { isRepo: true, branch: st.branch, remote: url ? sanitizeRemote(url) : null, dirty: st.files.length > 0 };190    });191192    app.post('/git/status', async (req) => {193      const rootReal = await assertRootAllowed(config, parse(RootBody, req.body).root);194      return status(bin, rootReal);195    });196197    app.post('/git/diff', async (req) => {198      const body = parse(DiffBody, req.body);199      const rootReal = await assertRootAllowed(config, body.root);200      if (!(await repoTop(bin, rootReal))) throw new RtError(400, 'not_a_repo', 'project is not a git repository');201      const args = ['--literal-pathspecs', 'diff', '--no-ext-diff', '--no-textconv', '--no-color'];202      if (body.staged) args.push('--cached');203      if (body.rel !== undefined && body.rel !== '') {204        // Validated like any other path; may legitimately not exist (deleted file).205        const target = await resolveInsideEx(config, body.root, body.rel, { noFollowFinal: true });206        args.push('--', target.rel);207      }208      const r = await gitRead(bin, rootReal, args, MAX_DIFF_BYTES);209      if (r.code !== 0 && !r.truncated) throw new RtError(500, 'git_failed', 'git diff failed');210      return { diff: r.stdout, truncated: r.truncated };211    });212213    app.post('/git/clone', async (req) => clone(config, opts, bin, parse(CloneBody, req.body)));214  };215}216217export default createGitPlugin();218
Files main
Menu

Theme