git.js (9237B)
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 LC_ALL: 'C', 48 GIT_CONFIG_COUNT: String(base + 1), 49 [`GIT_CONFIG_KEY_${base}`]: 'safe.directory', 50 [`GIT_CONFIG_VALUE_${base}`]: '*', 51 }; 52 } 53 54 const GIT_ENV = gitEnv(); 55 56 export { gitEnv }; 57 58 export class GitError extends Error { 59 constructor(message, { stderr, code } = {}) { 60 super(stderr ? `${message}: ${String(stderr).trim().split('\n').slice(0, 3).join('; ')}` : message); 61 this.name = 'GitError'; 62 this.stderr = stderr; 63 this.exitCode = code; 64 } 65 } 66 67 export function assertSha(sha, label = 'commit') { 68 if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) { 69 throw new GitError(`${label} must be a full 40 character hex object id, got ${JSON.stringify(sha)}`); 70 } 71 return sha; 72 } 73 74 // Refs come straight from a push hook. Reject anything git itself would 75 // consider malformed, along with leading hyphens. 76 export function assertRef(ref) { 77 if (typeof ref !== 'string' || ref.length === 0 || ref.length > 255) { 78 throw new GitError(`invalid ref: ${JSON.stringify(ref)}`); 79 } 80 if (ref.startsWith('-') || ref.includes('..') || /[\s~^:?*[\\]/.test(ref) || ref.endsWith('.lock')) { 81 throw new GitError(`invalid ref: ${JSON.stringify(ref)}`); 82 } 83 return ref; 84 } 85 86 // A path inside the repository, used for the pipeline file. 87 export function assertRepoPath(p) { 88 if (typeof p !== 'string' || p.length === 0 || p.length > 512) { 89 throw new GitError(`invalid repository path: ${JSON.stringify(p)}`); 90 } 91 if (p.startsWith('/') || p.startsWith('-') || p.split('/').some((s) => s === '' || s === '.' || s === '..')) { 92 throw new GitError(`invalid repository path: ${JSON.stringify(p)}`); 93 } 94 return p; 95 } 96 97 // Serializes work per mirror, so two pushes to one project cannot run 98 // concurrent fetches into the same directory. 99 const locks = new Map(); 100 101 async function withLock(key, fn) { 102 const previous = locks.get(key) ?? Promise.resolve(); 103 let release; 104 const current = new Promise((resolve) => { release = resolve; }); 105 locks.set(key, previous.then(() => current)); 106 await previous; 107 try { 108 return await fn(); 109 } finally { 110 release(); 111 if (locks.get(key) === current) locks.delete(key); 112 } 113 } 114 115 export function createGit(cfg) { 116 const timeout = cfg.git.timeout * 1000; 117 const fetchInterval = cfg.git.fetch_interval * 1000; 118 const lastFetch = new Map(); 119 120 function mirrorPath(projectId) { 121 return path.join(cfg.git.mirror_path, `${projectId}.git`); 122 } 123 124 // Given --git-dir explicitly, git still treats the process working 125 // directory as a work tree. That makes the result depend on whatever 126 // happens to sit in the conductor's own directory: asking for a file 127 // that is absent from a commit reports "exists on disk, but not in 128 // <sha>" rather than a plain absence, purely because a file of that 129 // name is next to the running process. Working inside the bare mirror 130 // takes the stray work tree out of the picture. 131 // 132 // Only when it is already there, since clone is told --git-dir for a 133 // directory it is about to create. 134 function repoCwd(args) { 135 const i = args.indexOf('--git-dir'); 136 if (i < 0) return undefined; 137 const dir = args[i + 1]; 138 return dir && existsSync(dir) ? dir : undefined; 139 } 140 141 async function run(args, options = {}) { 142 try { 143 const { stdout, stderr } = await execFileAsync('git', args, { 144 env: GIT_ENV, 145 cwd: options.cwd ?? repoCwd(args), 146 timeout, 147 maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, 148 encoding: options.encoding ?? 'utf8', 149 }); 150 return { stdout, stderr }; 151 } catch (e) { 152 throw new GitError(`git ${args[0]} failed`, { stderr: e.stderr || e.message, code: e.code }); 153 } 154 } 155 156 async function exists(dir) { 157 try { 158 await fs.stat(path.join(dir, 'HEAD')); 159 return true; 160 } catch { 161 return false; 162 } 163 } 164 165 return { 166 mirrorPath, 167 168 // Clones on first use, then fetches at most once per fetch_interval 169 // unless force is set. A trigger always forces, since it needs the 170 // commit that was just pushed. 171 async sync(project, { force = false } = {}) { 172 const dir = mirrorPath(project.id); 173 return withLock(dir, async () => { 174 if (!(await exists(dir))) { 175 await fs.mkdir(path.dirname(dir), { recursive: true }); 176 await run(['clone', '--mirror', '--quiet', '--', project.repo_url, dir]); 177 lastFetch.set(dir, Date.now()); 178 return dir; 179 } 180 181 const since = Date.now() - (lastFetch.get(dir) ?? 0); 182 if (!force && since < fetchInterval) return dir; 183 184 await run(['--git-dir', dir, 'fetch', '--prune', '--quiet', 'origin']); 185 lastFetch.set(dir, Date.now()); 186 return dir; 187 }); 188 }, 189 190 async hasCommit(project, sha) { 191 assertSha(sha); 192 try { 193 const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'cat-file', '-t', sha]); 194 return stdout.trim() === 'commit'; 195 } catch { 196 return false; 197 } 198 }, 199 200 async resolve(project, rev) { 201 assertRef(rev); 202 const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'rev-parse', '--verify', `${rev}^{commit}`]); 203 return stdout.trim(); 204 }, 205 206 // Reads one file at a commit. Returns null when the path is absent, 207 // which is how a repository without a pipeline is detected. 208 async readFile(project, sha, filePath) { 209 assertSha(sha); 210 assertRepoPath(filePath); 211 try { 212 const { stdout } = await run( 213 ['--git-dir', mirrorPath(project.id), 'cat-file', 'blob', `${sha}:${filePath}`], 214 { maxBuffer: 4 * 1024 * 1024 } 215 ); 216 return stdout; 217 } catch (e) { 218 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; 219 throw e; 220 } 221 }, 222 223 async commitInfo(project, sha) { 224 assertSha(sha); 225 // A unit separator keeps the fields unambiguous when a subject 226 // contains anything at all. 227 const { stdout } = await run([ 228 '--git-dir', mirrorPath(project.id), 229 'show', '--no-patch', '--format=%s%x1f%an%x1f%ae%x1f%aI', sha, 230 ]); 231 const [subject = '', authorName = '', authorEmail = '', authoredAt = ''] = stdout.trim().split('\x1f'); 232 return { subject, authorName, authorEmail, authoredAt }; 233 }, 234 235 // A gzipped tar of the tree at a commit, streamed rather than buffered. 236 // The caller pipes this straight to the worker. 237 archiveStream(project, sha, { prefix = '' } = {}) { 238 assertSha(sha); 239 const args = ['--git-dir', mirrorPath(project.id), 'archive', '--format=tar.gz']; 240 if (prefix) args.push(`--prefix=${prefix}`); 241 args.push(sha); 242 243 const child = spawn('git', args, { env: GIT_ENV, cwd: repoCwd(args), stdio: ['ignore', 'pipe', 'pipe'] }); 244 let stderr = ''; 245 child.stderr.on('data', (chunk) => { stderr += chunk.toString().slice(0, 4096); }); 246 child.on('close', (code) => { 247 if (code !== 0) child.stdout.destroy(new GitError('git archive failed', { stderr, code })); 248 }); 249 return child.stdout; 250 }, 251 }; 252 }