Repository Public
1/**2 * The only two git invocations of the server: `git init` and `git clone`. Always an argv array3 * (never a shell), a minimal environment (no API keys, no prompts), a hard timeout, and a URL4 * whitelist. Everything else about a repository is read from files, never by running git in it.5 */6import { execFile } from 'node:child_process';7import fs from 'node:fs';8import path from 'node:path';9import { HttpError } from '../../http.js';1011/**12 * Accepted clone URLs: `https://…`, `ssh://…`, `git@host:path`, `gitsrv:path` (the cluster's git13 * alias). No whitespace or control characters, no leading `-`, no `ext::`/`file:`/local paths.14 */15const URL_PATTERNS = [16 /^https:\/\/[A-Za-z0-9._~:@%+-]+(\/[A-Za-z0-9._~%+@/-]*)?$/,17 /^ssh:\/\/[A-Za-z0-9._~@-]+(:\d{1,5})?\/[A-Za-z0-9._~%+@/-]+$/,18 /^git@[A-Za-z0-9.-]+:[A-Za-z0-9._~/-]+$/,19 /^gitsrv:[A-Za-z0-9._~/-]+$/,20];21export function validateGitUrl(url: string): string {22 if (url.length > 500 || !URL_PATTERNS.some((re) => re.test(url)) || url.includes('..')) {23 throw new HttpError(400, 'invalid_git_url', 'git URL not allowed (https://, ssh://, git@host:path or gitsrv:path)');24 }25 return url;26}2728/** Removes `user:password@` from an http(s) URL so credentials are never stored, logged or shown. */29export function sanitizeRemote(url: string | null): string | null {30 if (!url) return null;31 const clean = url.trim().slice(0, 500);32 return /^https?:\/\//i.test(clean) ? clean.replace(/^(https?:\/\/)[^/@]*@/i, '$1') : clean;33}3435export function gitEnv(): NodeJS.ProcessEnv {36 const env: NodeJS.ProcessEnv = {37 PATH: process.env.PATH ?? '/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin',38 HOME: process.env.HOME ?? '/',39 LANG: 'C',40 GIT_TERMINAL_PROMPT: '0',41 GIT_ASKPASS: '/usr/bin/false',42 SSH_ASKPASS: '/usr/bin/false',43 GIT_SSH_COMMAND: 'ssh -oBatchMode=yes',44 GIT_CONFIG_NOSYSTEM: '1',45 };46 if (process.env.SSH_AUTH_SOCK) env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;47 return env;48}4950function git(args: string[], opts: { cwd?: string; timeoutMs: number }): Promise<void> {51 return new Promise((resolve, reject) => {52 execFile('git', args, { cwd: opts.cwd, env: gitEnv(), timeout: opts.timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, windowsHide: true }, (err) => {53 if (err) reject(err);54 else resolve();55 });56 });57}5859export async function gitInit(dir: string): Promise<void> {60 await git(['init', '--quiet'], { cwd: dir, timeoutMs: 30_000 });61}6263/**64 * Clones into `dest` (which must not exist). On ANY failure the partial directory is removed.65 * Dangerous transports are disabled even if a redirect or submodule asks for them.66 */67export async function gitClone(url: string, dest: string, timeoutMs: number): Promise<void> {68 try {69 await git(['-c', 'protocol.ext.allow=never', '-c', 'protocol.file.allow=never', '-c', 'core.hooksPath=/dev/null', 'clone', '--quiet', '--no-recurse-submodules', '--', url, dest], { timeoutMs });70 } catch (err) {71 fs.rmSync(dest, { recursive: true, force: true });72 const e = err as NodeJS.ErrnoException & { killed?: boolean };73 if (e.code === 'ENOENT') throw new HttpError(503, 'git_unavailable', 'git is not installed on this host');74 if (e.killed) throw new HttpError(504, 'clone_timeout', 'git clone timed out');75 // stderr may quote the URL (and credentials embedded in it): never forwarded.76 throw new HttpError(422, 'clone_failed', 'git clone failed (repository unreachable, not found or access denied)');77 }78}7980/** Current branch from `.git/HEAD` (null when detached, not a repository, or unreadable). */81export function readBranch(dir: string): string | null {82 try {83 const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8').slice(0, 400).trim();84 const m = /^ref: refs\/heads\/([^\s]{1,200})$/.exec(head);85 return m?.[1] ?? null;86 } catch {87 return null;88 }89}9091/** `remote.origin.url` from `.git/config`, sanitised (null when absent). */92export function readOriginUrl(dir: string): string | null {93 try {94 const stat = fs.statSync(path.join(dir, '.git', 'config'));95 if (!stat.isFile() || stat.size > 256 * 1024) return null;96 let inOrigin = false;97 for (const line of fs.readFileSync(path.join(dir, '.git', 'config'), 'utf8').split('\n')) {98 const t = line.trim();99 if (t.startsWith('[')) inOrigin = /^\[remote\s+"origin"\]$/.test(t);100 else if (inOrigin) {101 const m = /^url\s*=\s*(\S+)$/.exec(t);102 if (m?.[1]) return sanitizeRemote(m[1]);103 }104 }105 } catch {106 /* not a repository */107 }108 return null;109}110