Row 138 round 2, Rocko's candidate e3f9a400 (43 paths) on26a0b0fab. Dewey approved (comment 28754) after round 1 changes (comment 28752): D1-D5 mutants killed, the 17 touched test files run singly passed 124/124. Rocko's own run: 753 tests and 59/59 mutants killed. The bus gains the project adapter through runtime.mjs, the way it already injects the organization, so broker.mjs takes no new imports and refuses with project-not-configured when no adapter is set. The project socket timeout is 120s. Roles gain record.write. The schema moves from 3e to 3f; the live migration follows MIGRATION.md and needs Mos's window. Sage's landing gate ran onc842686d5plus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), network sage-r138_default removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
87 lines
4.5 KiB
JavaScript
87 lines
4.5 KiB
JavaScript
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';
|
|
|
|
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.
|
|
const walk = directory => {
|
|
const st = lstatSync(directory);
|
|
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-repository', 'expected local .git directory');
|
|
for (const name of readdirSync(directory)) {
|
|
const path = join(directory, name), entry = lstatSync(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'));
|
|
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 child = spawn('git', [...SAFE, '-C', repo, ...args], {
|
|
env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo) },
|
|
detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
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);
|
|
});
|
|
}
|