conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit abab20c7e28927bb75b489d139188b82cf578ea9
parent 644564b89914a5854c86480ff28a206d8d99be24
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 16:41:03 +0200

Read repositories the conductor does not own

Diffstat:
Msrc/lib/git.js | 62+++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Atest/git-env.test.js | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 169 insertions(+), 11 deletions(-)

diff --git a/src/lib/git.js b/src/lib/git.js @@ -16,6 +16,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { execFile, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); @@ -23,15 +24,36 @@ const execFileAsync = promisify(execFile); export const SHA_PATTERN = /^[0-9a-f]{40}$/; export const SHORT_SHA_PATTERN = /^[0-9a-f]{7,40}$/; -// Refuses credential prompts, so a misconfigured private repository fails -// promptly instead of hanging until the request times out. -const GIT_ENV = { - ...process.env, - GIT_TERMINAL_PROMPT: '0', - GIT_ASKPASS: '', - GCM_INTERACTIVE: 'never', - LC_ALL: 'C', -}; +// Reading a repository owned by somebody else is the normal case here, not +// a suspicious one: project paths belong to whoever put them on disk, and +// the conductor is a different user again once it runs in a container. +// Git refuses that by default with "detected dubious ownership", which is +// the right default for a shell and the wrong one for this. +// +// Applied through the environment rather than a global git config, so it +// covers both spawn and execFile below and affects nothing outside this +// process. Anything already set in the environment is preserved, since +// the indices have to be contiguous for git to read them. +function gitEnv(env = process.env) { + const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10); + const base = Number.isInteger(count) && count > 0 ? count : 0; + return { + ...env, + // Refuses credential prompts, so a misconfigured private repository + // fails promptly instead of hanging until the request times out. + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + GCM_INTERACTIVE: 'never', + LC_ALL: 'C', + GIT_CONFIG_COUNT: String(base + 1), + [`GIT_CONFIG_KEY_${base}`]: 'safe.directory', + [`GIT_CONFIG_VALUE_${base}`]: '*', + }; +} + +const GIT_ENV = gitEnv(); + +export { gitEnv }; export class GitError extends Error { constructor(message, { stderr, code } = {}) { @@ -99,10 +121,28 @@ export function createGit(cfg) { return path.join(cfg.git.mirror_path, `${projectId}.git`); } + // Given --git-dir explicitly, git still treats the process working + // directory as a work tree. That makes the result depend on whatever + // happens to sit in the conductor's own directory: asking for a file + // that is absent from a commit reports "exists on disk, but not in + // <sha>" rather than a plain absence, purely because a file of that + // name is next to the running process. Working inside the bare mirror + // takes the stray work tree out of the picture. + // + // Only when it is already there, since clone is told --git-dir for a + // directory it is about to create. + function repoCwd(args) { + const i = args.indexOf('--git-dir'); + if (i < 0) return undefined; + const dir = args[i + 1]; + return dir && existsSync(dir) ? dir : undefined; + } + async function run(args, options = {}) { try { const { stdout, stderr } = await execFileAsync('git', args, { env: GIT_ENV, + cwd: options.cwd ?? repoCwd(args), timeout, maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, encoding: options.encoding ?? 'utf8', @@ -175,7 +215,7 @@ export function createGit(cfg) { ); return stdout; } catch (e) { - if (/does not exist|not a valid object name|Not a valid object/i.test(e.stderr ?? '')) return null; + 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; throw e; } }, @@ -200,7 +240,7 @@ export function createGit(cfg) { if (prefix) args.push(`--prefix=${prefix}`); args.push(sha); - const child = spawn('git', args, { env: GIT_ENV, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn('git', args, { env: GIT_ENV, cwd: repoCwd(args), stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk.toString().slice(0, 4096); }); child.on('close', (code) => { diff --git a/test/git-env.test.js b/test/git-env.test.js @@ -0,0 +1,118 @@ +// test/git-env.test.js - reading repositories the conductor does not own +// +// Project paths belong to whoever put them on disk, and in a container the +// conductor is a different user again. Git calls that "dubious ownership" +// and refuses. This went unnoticed for a while because the old image ran +// as uid 1000, which happened to match the repositories being read, so it +// worked by coincidence rather than by design. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { gitEnv } from '../src/lib/git.js'; + +const execFileAsync = promisify(execFile); + +test('git runs with safe.directory set', () => { + const env = gitEnv({}); + assert.equal(env.GIT_CONFIG_COUNT, '1'); + assert.equal(env.GIT_CONFIG_KEY_0, 'safe.directory'); + assert.equal(env.GIT_CONFIG_VALUE_0, '*'); +}); + +test('it appends to configuration already in the environment', () => { + const env = gitEnv({ + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'http.version', + GIT_CONFIG_VALUE_0: 'HTTP/1.1', + GIT_CONFIG_KEY_1: 'core.quotePath', + GIT_CONFIG_VALUE_1: 'false', + }); + + // Git reads indices 0..count-1, so ours has to land at the end and the + // count has to grow rather than be replaced. + assert.equal(env.GIT_CONFIG_COUNT, '3'); + assert.equal(env.GIT_CONFIG_KEY_0, 'http.version'); + assert.equal(env.GIT_CONFIG_KEY_1, 'core.quotePath'); + assert.equal(env.GIT_CONFIG_KEY_2, 'safe.directory'); + assert.equal(env.GIT_CONFIG_VALUE_2, '*'); +}); + +test('a count that is not a number does not corrupt the sequence', () => { + for (const bad of ['', 'garbage', '-1', '0']) { + const env = gitEnv({ GIT_CONFIG_COUNT: bad }); + assert.equal(env.GIT_CONFIG_COUNT, '1', `count ${JSON.stringify(bad)}`); + assert.equal(env.GIT_CONFIG_KEY_0, 'safe.directory'); + } +}); + +test('git accepts the environment we build', async () => { + // Proves the variables are actually well formed. A malformed set makes + // git fail outright, which is what this would catch. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-gitenv-')); + try { + const env = gitEnv(process.env); + await execFileAsync('git', ['init', '-q', '-b', 'main', dir], { env }); + const { stdout } = await execFileAsync( + 'git', ['-C', dir, 'config', '--get', 'safe.directory'], { env }, + ); + assert.equal(stdout.trim(), '*'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('a file missing from a commit reads as missing, whatever sits in the working directory', async () => { + // Given --git-dir, git treats the process working directory as a work + // tree and changes its wording when a file of that name happens to be + // there: "exists on disk, but not in <sha>" rather than a plain + // absence. The conductor decides a project has no pipeline from that + // message, so the wrong wording turned a missing pipeline into a 500. + // Reproduced here rather than left to the happy accident that the test + // suite runs from a directory containing a .conductor.yml. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-cwd-')); + const work = path.join(dir, 'work'); + const mirror = path.join(dir, 'mirror.git'); + const env = gitEnv(process.env); + + try { + await fs.mkdir(work); + await execFileAsync('git', ['init', '-q', '-b', 'main', work], { env }); + await execFileAsync('git', ['-C', work, 'config', 'user.email', 't@example.invalid'], { env }); + await execFileAsync('git', ['-C', work, 'config', 'user.name', 'test'], { env }); + await fs.writeFile(path.join(work, 'other.txt'), 'present\n'); + await execFileAsync('git', ['-C', work, 'add', '-A'], { env }); + await execFileAsync('git', ['-C', work, 'commit', '-q', '-m', 'only other.txt'], { env }); + await execFileAsync('git', ['clone', '-q', '--mirror', work, mirror], { env }); + + const { stdout } = await execFileAsync('git', ['--git-dir', mirror, 'rev-parse', 'HEAD'], { env }); + const sha = stdout.trim(); + + // A name that is absent from the commit but present in the directory + // the suite runs from. + const decoy = 'package.json'; + assert.ok(existsSync(path.join(process.cwd(), decoy)), `expected ${decoy} in the working directory`); + + let stderr = ''; + try { + await execFileAsync( + 'git', ['--git-dir', mirror, 'cat-file', 'blob', `${sha}:${decoy}`], + { env, cwd: mirror }, + ); + assert.fail('expected git to report the path as absent'); + } catch (error) { + stderr = error.stderr ?? ''; + } + + // Run inside the bare mirror there is no work tree to confuse it. + assert.match(stderr, /does not exist/); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +});