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