git.js (9462B)
1 // src/lib/git.js - repository mirrors 2 // 3 // The conductor keeps one bare mirror per project. Everything it needs from 4 // a push is read out of that mirror: the pipeline file at the pushed commit, 5 // the commit subject, and the source tarball handed to workers. 6 // 7 // Serving the source from here rather than having workers clone means an 8 // untrusted worker needs no repository credentials and cannot reach any ref 9 // other than the commit it was given work for. It also means the worker host 10 // does not need git installed. 11 // 12 // Every value interpolated into a git invocation is validated first. Refs 13 // and object ids arrive from webhooks, and a value beginning with a hyphen 14 // would otherwise be read as an option. 15 16 import fs from 'node:fs/promises'; 17 import path from 'node:path'; 18 import { execFile, spawn } from 'node:child_process'; 19 import { existsSync } from 'node:fs'; 20 import { promisify } from 'node:util'; 21 22 const execFileAsync = promisify(execFile); 23 24 export const SHA_PATTERN = /^[0-9a-f]{40}$/; 25 export const SHORT_SHA_PATTERN = /^[0-9a-f]{7,40}$/; 26 27 // Reading a repository owned by somebody else is the normal case here, not 28 // a suspicious one: project paths belong to whoever put them on disk, and 29 // the conductor is a different user again once it runs in a container. 30 // Git refuses that by default with "detected dubious ownership", which is 31 // the right default for a shell and the wrong one for this. 32 // 33 // Applied through the environment rather than a global git config, so it 34 // covers both spawn and execFile below and affects nothing outside this 35 // process. Anything already set in the environment is preserved, since 36 // the indices have to be contiguous for git to read them. 37 function gitEnv(env = process.env) { 38 const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10); 39 const base = Number.isInteger(count) && count > 0 ? count : 0; 40 return { 41 ...env, 42 // Refuses credential prompts, so a misconfigured private repository 43 // fails promptly instead of hanging until the request times out. 44 GIT_TERMINAL_PROMPT: '0', 45 GIT_ASKPASS: '', 46 GCM_INTERACTIVE: 'never', 47 // Backstop for assertRepoUrl: blocks ext:: and every other helper 48 // transport. file stays for local mirrors, which assertRepoUrl gates. 49 GIT_ALLOW_PROTOCOL: 'file:git:ssh:https', 50 GIT_PROTOCOL_FROM_USER: '0', 51 LC_ALL: 'C', 52 GIT_CONFIG_COUNT: String(base + 1), 53 [`GIT_CONFIG_KEY_${base}`]: 'safe.directory', 54 [`GIT_CONFIG_VALUE_${base}`]: '*', 55 }; 56 } 57 58 const GIT_ENV = gitEnv(); 59 60 export { gitEnv }; 61 62 export class GitError extends Error { 63 constructor(message, { stderr, code } = {}) { 64 super(stderr ? `${message}: ${String(stderr).trim().split('\n').slice(0, 3).join('; ')}` : message); 65 this.name = 'GitError'; 66 this.stderr = stderr; 67 this.exitCode = code; 68 } 69 } 70 71 export function assertSha(sha, label = 'commit') { 72 if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) { 73 throw new GitError(`${label} must be a full 40 character hex object id, got ${JSON.stringify(sha)}`); 74 } 75 return sha; 76 } 77 78 // Refs come straight from a push hook. Reject anything git itself would 79 // consider malformed, along with leading hyphens. 80 export function assertRef(ref) { 81 if (typeof ref !== 'string' || ref.length === 0 || ref.length > 255) { 82 throw new GitError(`invalid ref: ${JSON.stringify(ref)}`); 83 } 84 if (ref.startsWith('-') || ref.includes('..') || /[\s~^:?*[\\]/.test(ref) || ref.endsWith('.lock')) { 85 throw new GitError(`invalid ref: ${JSON.stringify(ref)}`); 86 } 87 return ref; 88 } 89 90 // A path inside the repository, used for the pipeline file. 91 export function assertRepoPath(p) { 92 if (typeof p !== 'string' || p.length === 0 || p.length > 512) { 93 throw new GitError(`invalid repository path: ${JSON.stringify(p)}`); 94 } 95 if (p.startsWith('/') || p.startsWith('-') || p.split('/').some((s) => s === '' || s === '.' || s === '..')) { 96 throw new GitError(`invalid repository path: ${JSON.stringify(p)}`); 97 } 98 return p; 99 } 100 101 // Serializes work per mirror, so two pushes to one project cannot run 102 // concurrent fetches into the same directory. 103 const locks = new Map(); 104 105 async function withLock(key, fn) { 106 const previous = locks.get(key) ?? Promise.resolve(); 107 let release; 108 const current = new Promise((resolve) => { release = resolve; }); 109 locks.set(key, previous.then(() => current)); 110 await previous; 111 try { 112 return await fn(); 113 } finally { 114 release(); 115 if (locks.get(key) === current) locks.delete(key); 116 } 117 } 118 119 export function createGit(cfg) { 120 const timeout = cfg.git.timeout * 1000; 121 const fetchInterval = cfg.git.fetch_interval * 1000; 122 const lastFetch = new Map(); 123 124 function mirrorPath(projectId) { 125 return path.join(cfg.git.mirror_path, `${projectId}.git`); 126 } 127 128 // Given --git-dir explicitly, git still treats the process working 129 // directory as a work tree. That makes the result depend on whatever 130 // happens to sit in the conductor's own directory: asking for a file 131 // that is absent from a commit reports "exists on disk, but not in 132 // <sha>" rather than a plain absence, purely because a file of that 133 // name is next to the running process. Working inside the bare mirror 134 // takes the stray work tree out of the picture. 135 // 136 // Only when it is already there, since clone is told --git-dir for a 137 // directory it is about to create. 138 function repoCwd(args) { 139 const i = args.indexOf('--git-dir'); 140 if (i < 0) return undefined; 141 const dir = args[i + 1]; 142 return dir && existsSync(dir) ? dir : undefined; 143 } 144 145 async function run(args, options = {}) { 146 try { 147 const { stdout, stderr } = await execFileAsync('git', args, { 148 env: GIT_ENV, 149 cwd: options.cwd ?? repoCwd(args), 150 timeout, 151 maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, 152 encoding: options.encoding ?? 'utf8', 153 }); 154 return { stdout, stderr }; 155 } catch (e) { 156 throw new GitError(`git ${args[0]} failed`, { stderr: e.stderr || e.message, code: e.code }); 157 } 158 } 159 160 async function exists(dir) { 161 try { 162 await fs.stat(path.join(dir, 'HEAD')); 163 return true; 164 } catch { 165 return false; 166 } 167 } 168 169 return { 170 mirrorPath, 171 172 // Clones on first use, then fetches at most once per fetch_interval 173 // unless force is set. A trigger always forces, since it needs the 174 // commit that was just pushed. 175 async sync(project, { force = false } = {}) { 176 const dir = mirrorPath(project.id); 177 return withLock(dir, async () => { 178 if (!(await exists(dir))) { 179 await fs.mkdir(path.dirname(dir), { recursive: true }); 180 await run(['clone', '--mirror', '--quiet', '--', project.repo_url, dir]); 181 lastFetch.set(dir, Date.now()); 182 return dir; 183 } 184 185 const since = Date.now() - (lastFetch.get(dir) ?? 0); 186 if (!force && since < fetchInterval) return dir; 187 188 await run(['--git-dir', dir, 'fetch', '--prune', '--quiet', 'origin']); 189 lastFetch.set(dir, Date.now()); 190 return dir; 191 }); 192 }, 193 194 async hasCommit(project, sha) { 195 assertSha(sha); 196 try { 197 const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'cat-file', '-t', sha]); 198 return stdout.trim() === 'commit'; 199 } catch { 200 return false; 201 } 202 }, 203 204 async resolve(project, rev) { 205 assertRef(rev); 206 const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'rev-parse', '--verify', `${rev}^{commit}`]); 207 return stdout.trim(); 208 }, 209 210 // Reads one file at a commit. Returns null when the path is absent, 211 // which is how a repository without a pipeline is detected. 212 async readFile(project, sha, filePath) { 213 assertSha(sha); 214 assertRepoPath(filePath); 215 try { 216 const { stdout } = await run( 217 ['--git-dir', mirrorPath(project.id), 'cat-file', 'blob', `${sha}:${filePath}`], 218 { maxBuffer: 4 * 1024 * 1024 } 219 ); 220 return stdout; 221 } catch (e) { 222 if (/does not exist|exists on disk, but not in|not a valid object name|Not a valid object/i.test(e.stderr ?? '')) return null; 223 throw e; 224 } 225 }, 226 227 async commitInfo(project, sha) { 228 assertSha(sha); 229 // A unit separator keeps the fields unambiguous when a subject 230 // contains anything at all. 231 const { stdout } = await run([ 232 '--git-dir', mirrorPath(project.id), 233 'show', '--no-patch', '--format=%s%x1f%an%x1f%ae%x1f%aI', sha, 234 ]); 235 const [subject = '', authorName = '', authorEmail = '', authoredAt = ''] = stdout.trim().split('\x1f'); 236 return { subject, authorName, authorEmail, authoredAt }; 237 }, 238 239 // A gzipped tar of the tree at a commit, streamed rather than buffered. 240 // The caller pipes this straight to the worker. 241 archiveStream(project, sha, { prefix = '' } = {}) { 242 assertSha(sha); 243 const args = ['--git-dir', mirrorPath(project.id), 'archive', '--format=tar.gz']; 244 if (prefix) args.push(`--prefix=${prefix}`); 245 args.push(sha); 246 247 const child = spawn('git', args, { env: GIT_ENV, cwd: repoCwd(args), stdio: ['ignore', 'pipe', 'pipe'] }); 248 let stderr = ''; 249 child.stderr.on('data', (chunk) => { stderr += chunk.toString().slice(0, 4096); }); 250 child.on('close', (code) => { 251 if (code !== 0) child.stdout.destroy(new GitError('git archive failed', { stderr, code })); 252 }); 253 return child.stdout; 254 }, 255 }; 256 }