import { lstatSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { spawn } from 'node:child_process'; import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs'; import { ProjectError } from './paths.mjs'; import { currentProcessing } from '../../processing-control/src/effects.mjs'; export const SAFE = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.ignorestat=false', '-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', '-c', 'commit.gpgsign=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0']; export async function git(repo, args, identity = {}) { const result = await runGit([...SAFE, '-C', repo, ...args], { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo), ...identity, }); if (!result.ok) throw new ProjectError('project-git', `local git ${args[0]} refused (${result.why})`); return result.out.trimEnd(); } export async function inspectGit(repo) { // Refuse gitdir indirection, alternates, links or hardlinks before git reads metadata. // Everything below .git was named by a readdir, so a name that is gone when it is read was // removed by a concurrent git process (for example a pack temp file). That refuses; it is // never skipped or retried, because the metadata did not hold still while it was checked. const changed = e => e.code === 'ENOENT' ? new ProjectError('project-repository', 'git metadata changed during inspection') : e; const statEntry = path => { try { return lstatSync(path); } catch (e) { throw changed(e); } }; const namesIn = directory => { try { return readdirSync(directory); } catch (e) { throw changed(e); } }; // Every directory takes a fresh lstat when the walk enters it, so one replaced by a link or a // file after its listed stat still refuses. The root is not a listed name: a missing .git // keeps its own error, as before. const walk = (directory, root = false) => { const st = root ? lstatSync(directory) : statEntry(directory); if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-repository', 'expected local .git directory'); for (const name of namesIn(directory)) { const path = join(directory, name), entry = statEntry(path); if (entry.isDirectory()) walk(path); else if (!entry.isFile() || entry.nlink !== 1) throw new ProjectError('project-repository', 'git metadata must contain regular private files'); } }; walk(join(repo, '.git'), true); for (const name of ['commondir', 'objects/info/alternates', 'objects/info/http-alternates']) { try { lstatSync(join(repo, '.git', name)); } catch (e) { if (e.code === 'ENOENT') continue; throw e; } throw new ProjectError('project-repository', 'git metadata indirection refused'); } const config = lstatSync(join(repo, '.git/config')); if (config.size > 65536) throw new ProjectError('project-repository', 'git config is too large'); const list = await git(repo, ['config', '--no-includes', '--file', join(repo, '.git/config'), '--list', '--null']); const allowed = { 'core.repositoryformatversion': ['0'], 'core.bare': ['false'], 'core.filemode': ['true', 'false'], 'core.logallrefupdates': ['true'], 'core.ignorecase': ['true', 'false'], }; for (const entry of list.split('\0').filter(Boolean)) { const split = entry.indexOf('\n'), key = entry.slice(0, split), value = entry.slice(split + 1); if (key.startsWith('remote.')) throw new ProjectError('project-remote', 'project repository must have no remote'); if (['user.name', 'user.email'].includes(key)) continue; if (!allowed[key]?.includes(value)) throw new ProjectError('project-repository', `unsupported local git config: ${key}`); } } export async function requireClean(repo) { const status = await git(repo, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']); const flags = await git(repo, ['ls-files', '-v']); if (status || flags.split('\n').some(line => /^[a-zS] /.test(line))) throw new ProjectError('project-dirty', 'pending edits, staged changes, ignored content or hidden index flags refuse'); } export const identity = name => ({ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: `${name}@mosaic.invalid`, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: `${name}@mosaic.invalid` }); // Blob/log reads preserve bytes and bound memory before decoding untrusted Git data. export async function gitBytes(repo, args, maxBytes) { return new Promise((resolve, reject) => { const processing = currentProcessing(); processing?.guard(); const child = spawn('git', [...SAFE, '-C', repo, ...args], { env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo) }, detached: true, stdio: ['ignore', 'pipe', 'pipe'], }); processing?.track(child); let size = 0, failed = false, settled = false; const chunks = []; const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }; const timer = setTimeout(() => { failed = true; kill(); }, GIT_LIMITS.timeoutMs); const finish = (code) => { if (settled) return; settled = true; clearTimeout(timer); if (failed || code !== 0) reject(new ProjectError('project-git', 'bounded local Git read refused')); else resolve(Buffer.concat(chunks)); }; child.stdout.on('data', chunk => { size += chunk.length; if (size > maxBytes) { failed = true; kill(); } else chunks.push(chunk); }); // Drain stderr without retaining repository-controlled text. child.stderr.on('data', () => {}); child.on('error', () => finish(-1)); child.on('close', finish); }); }