mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
isTrustedLocalOrigin realpath'd a relative origin against process.cwd(), but `git -C repoPath pull` resolves relative file remotes against the repo dir — the containment guard was checking a different path than the one git actually pulls. Resolve relative origins against repoPath before the realpath containment check. Regression test mutation-checked (fails on the cwd-based resolution). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
541 lines
21 KiB
TypeScript
541 lines
21 KiB
TypeScript
import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, chmodSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
GIT_SSRF_FLAGS,
|
|
GIT_SSRF_FLAGS_LOCAL,
|
|
GIT_SSRF_SUBCOMMAND_FLAGS,
|
|
parseRemoteUrl,
|
|
RemoteUrlError,
|
|
cloneRepo,
|
|
pullRepo,
|
|
GitOperationError,
|
|
validateRepoState,
|
|
} from '../src/core/git-remote.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake-git harness: write a shell script that records its argv to a log file,
|
|
// then prepend its dir to PATH for the test. Lets us assert exact argv shape
|
|
// without invoking real git.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FAKE_GIT_DIR = join(tmpdir(), `gbrain-git-remote-test-${process.pid}`);
|
|
const FAKE_GIT_LOG = join(FAKE_GIT_DIR, 'argv.log');
|
|
const FAKE_GIT_MODE = join(FAKE_GIT_DIR, 'mode');
|
|
const FAKE_GIT_ORIGIN = join(FAKE_GIT_DIR, 'origin-url');
|
|
|
|
function writeFakeGit(): void {
|
|
mkdirSync(FAKE_GIT_DIR, { recursive: true });
|
|
// Mode file controls fake-git behavior: "ok" = exit 0, "fail" = exit 1.
|
|
writeFileSync(FAKE_GIT_MODE, 'ok');
|
|
// Per-invocation argv goes into argv.log (one JSON array per line).
|
|
writeFileSync(FAKE_GIT_LOG, '');
|
|
// Origin URL emitted by fake-git for `remote get-url` in local-origin mode.
|
|
writeFileSync(FAKE_GIT_ORIGIN, '');
|
|
const script = `#!/usr/bin/env bash
|
|
# Fake git for git-remote.test.ts
|
|
{ printf '['; for arg in "$@"; do printf '%s,' "$(printf '%s' "$arg" | jq -Rs .)"; done; printf 'null]\\n'; } >> "${FAKE_GIT_LOG}"
|
|
mode=$(cat "${FAKE_GIT_MODE}" 2>/dev/null || echo ok)
|
|
case "$mode" in
|
|
fail) exit 1 ;;
|
|
url-drift) echo "https://github.com/different/url" ;;
|
|
url-match) echo "https://github.com/expected/url" ;;
|
|
local-origin)
|
|
case " $* " in
|
|
*" symbolic-ref "*) echo "main" ;;
|
|
*" get-url "*) cat "${FAKE_GIT_ORIGIN}" 2>/dev/null || true ;;
|
|
*) : ;;
|
|
esac
|
|
;;
|
|
*) ;;
|
|
esac
|
|
exit 0
|
|
`;
|
|
const path = join(FAKE_GIT_DIR, 'git');
|
|
writeFileSync(path, script);
|
|
chmodSync(path, 0o755);
|
|
}
|
|
|
|
function readArgvLog(): string[][] {
|
|
const raw = readFileSync(FAKE_GIT_LOG, 'utf8');
|
|
return raw
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map(line => {
|
|
const arr = JSON.parse(line) as (string | null)[];
|
|
return arr.filter((x): x is string => x !== null);
|
|
});
|
|
}
|
|
|
|
function clearArgvLog(): void {
|
|
writeFileSync(FAKE_GIT_LOG, '');
|
|
}
|
|
|
|
function setMode(mode: 'ok' | 'fail' | 'url-drift' | 'url-match' | 'local-origin'): void {
|
|
writeFileSync(FAKE_GIT_MODE, mode);
|
|
}
|
|
|
|
function setOrigin(url: string): void {
|
|
writeFileSync(FAKE_GIT_ORIGIN, url);
|
|
}
|
|
|
|
beforeAll(() => writeFakeGit());
|
|
afterAll(() => rmSync(FAKE_GIT_DIR, { recursive: true, force: true }));
|
|
beforeEach(() => {
|
|
clearArgvLog();
|
|
setMode('ok');
|
|
});
|
|
|
|
const fakePath = (): string => `${FAKE_GIT_DIR}:${process.env.PATH ?? ''}`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIT_SSRF_FLAGS — pinned shape (snapshot test). If a future flag is added,
|
|
// update the expected list here AND verify both cloneRepo + pullRepo pick it
|
|
// up via the GIT_SSRF_FLAGS spread (the codex finding that motivated this).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GIT_SSRF_FLAGS', () => {
|
|
test('exact shape — global -c config flags only (spread BEFORE the verb)', () => {
|
|
expect([...GIT_SSRF_FLAGS]).toEqual([
|
|
'-c', 'http.followRedirects=false',
|
|
'-c', 'protocol.file.allow=never',
|
|
'-c', 'protocol.ext.allow=never',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('GIT_SSRF_SUBCOMMAND_FLAGS', () => {
|
|
test('exact shape — subcommand-level flags only (spread AFTER the verb)', () => {
|
|
// v0.34 fix wave: --no-recurse-submodules is a clone/pull subcommand
|
|
// flag, not a global flag. Real git exits 129 with "unknown option"
|
|
// when it appears before the verb. The pre-v0.34 single-constant
|
|
// spread baked the bug in.
|
|
expect([...GIT_SSRF_SUBCOMMAND_FLAGS]).toEqual([
|
|
'--no-recurse-submodules',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseRemoteUrl
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('parseRemoteUrl — happy path', () => {
|
|
test('accepts plain https URL', () => {
|
|
const r = parseRemoteUrl('https://github.com/garrytan/dummy.git');
|
|
expect(r.url).toBe('https://github.com/garrytan/dummy.git');
|
|
expect(r.hostname).toBe('github.com');
|
|
});
|
|
});
|
|
|
|
describe('parseRemoteUrl — rejection cases', () => {
|
|
test('rejects empty input', () => {
|
|
expect(() => parseRemoteUrl('')).toThrow(RemoteUrlError);
|
|
});
|
|
test('rejects malformed URL', () => {
|
|
expect(() => parseRemoteUrl('not a url')).toThrow(/malformed|invalid_url/i);
|
|
});
|
|
test('rejects ssh:// scheme', () => {
|
|
try {
|
|
parseRemoteUrl('ssh://git@github.com/foo/bar.git');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('unsupported_scheme');
|
|
}
|
|
});
|
|
test('rejects git:// scheme', () => {
|
|
expect(() => parseRemoteUrl('git://github.com/foo/bar')).toThrow(/scheme not supported/i);
|
|
});
|
|
test('rejects file:// scheme', () => {
|
|
expect(() => parseRemoteUrl('file:///etc/passwd')).toThrow(/scheme not supported/i);
|
|
});
|
|
test('rejects embedded credentials', () => {
|
|
try {
|
|
parseRemoteUrl('https://user:pass@github.com/foo');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('embedded_credentials');
|
|
}
|
|
});
|
|
test('rejects path traversal (..)', () => {
|
|
try {
|
|
parseRemoteUrl('https://github.com/foo/../etc/passwd');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('path_traversal');
|
|
}
|
|
});
|
|
test('rejects RFC1918 192.168.x.x', () => {
|
|
try {
|
|
parseRemoteUrl('https://192.168.1.1/repo.git');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('internal_target');
|
|
}
|
|
});
|
|
test('rejects loopback 127.0.0.1', () => {
|
|
expect(() => parseRemoteUrl('https://127.0.0.1/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects localhost', () => {
|
|
expect(() => parseRemoteUrl('https://localhost/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects metadata.google.internal', () => {
|
|
expect(() => parseRemoteUrl('https://metadata.google.internal/foo')).toThrow(
|
|
/internal/i,
|
|
);
|
|
});
|
|
test('rejects 169.254.x.x AWS metadata range', () => {
|
|
expect(() => parseRemoteUrl('https://169.254.169.254/foo')).toThrow(/internal/i);
|
|
});
|
|
|
|
// Codex v0.28.1 finding: IPv6 ULA + link-local were not blocked.
|
|
test('rejects IPv6 ULA fc00::/7 (fd-prefix)', () => {
|
|
expect(() => parseRemoteUrl('https://[fd00:1234::1]/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects IPv6 ULA fc00::/7 (fc-prefix)', () => {
|
|
expect(() => parseRemoteUrl('https://[fc01:2345::abcd]/repo')).toThrow(/internal/i);
|
|
});
|
|
test('rejects IPv6 link-local fe80::/10', () => {
|
|
expect(() => parseRemoteUrl('https://[fe80::1]/repo')).toThrow(/internal/i);
|
|
});
|
|
test('does NOT reject public IPv6', () => {
|
|
// 2606:4700:4700::1111 is Cloudflare DNS — public IPv6
|
|
const r = parseRemoteUrl('https://[2606:4700:4700::1111]/repo');
|
|
expect(r.hostname).toBe('[2606:4700:4700::1111]');
|
|
});
|
|
});
|
|
|
|
// T3 — Tailscale CGNAT regression cases.
|
|
describe('parseRemoteUrl — CGNAT 100.64/10 (Tailscale)', () => {
|
|
test('rejected by default', async () => {
|
|
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
|
|
try {
|
|
parseRemoteUrl('https://100.64.0.1/repo.git');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(RemoteUrlError);
|
|
expect((e as RemoteUrlError).code).toBe('internal_target');
|
|
}
|
|
});
|
|
});
|
|
test('accepted with GBRAIN_ALLOW_PRIVATE_REMOTES=1', async () => {
|
|
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: '1' }, async () => {
|
|
const r = parseRemoteUrl('https://100.64.0.1/repo.git');
|
|
expect(r.hostname).toBe('100.64.0.1');
|
|
});
|
|
});
|
|
test('also covers 100.127.x (upper end of CGNAT range)', async () => {
|
|
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
|
|
expect(() => parseRemoteUrl('https://100.127.255.1/x')).toThrow(/internal/i);
|
|
});
|
|
});
|
|
test('does NOT reject 100.0.x (just below CGNAT range)', () => {
|
|
// 100.0.0.0/8 is regular public IP space outside CGNAT
|
|
const r = parseRemoteUrl('https://100.63.255.1/repo');
|
|
expect(r.hostname).toBe('100.63.255.1');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cloneRepo — fake-git harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('cloneRepo', () => {
|
|
test('happy path: invokes git with GIT_SSRF_FLAGS + --depth=1 + url + dest', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-target');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
cloneRepo('https://example.com/repo', dest);
|
|
});
|
|
const calls = readArgvLog();
|
|
expect(calls.length).toBe(1);
|
|
const argv = calls[0];
|
|
// Global -c config flags must appear BEFORE the 'clone' verb.
|
|
expect(argv.slice(0, GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(argv).toContain('clone');
|
|
expect(argv).toContain('--depth=1');
|
|
expect(argv).toContain('https://example.com/repo');
|
|
expect(argv[argv.length - 1]).toBe(dest);
|
|
// v0.34 fix wave: subcommand flags MUST appear after the verb. Real
|
|
// git rejects `git --no-recurse-submodules clone ...` with exit 129.
|
|
// The fake-git harness returned 0 for any argv shape, so this
|
|
// position-anchored assertion is the structural regression test.
|
|
const cloneIdx = argv.indexOf('clone');
|
|
expect(cloneIdx).toBeGreaterThan(-1);
|
|
for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
|
|
const flagIdx = argv.indexOf(subFlag);
|
|
expect(flagIdx).toBeGreaterThan(cloneIdx);
|
|
}
|
|
});
|
|
|
|
test('depth=0 means no --depth flag (full clone)', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-full');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
cloneRepo('https://example.com/repo', dest, { depth: 0 });
|
|
});
|
|
const argv = readArgvLog()[0];
|
|
expect(argv.find(a => a.startsWith('--depth'))).toBeUndefined();
|
|
});
|
|
|
|
test('passes --branch when provided', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-branch');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
cloneRepo('https://example.com/repo', dest, { branch: 'main' });
|
|
});
|
|
const argv = readArgvLog()[0];
|
|
const branchIdx = argv.indexOf('--branch');
|
|
expect(branchIdx).toBeGreaterThan(-1);
|
|
expect(argv[branchIdx + 1]).toBe('main');
|
|
});
|
|
|
|
test('refuses non-empty destDir', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-nonempty');
|
|
mkdirSync(dest, { recursive: true });
|
|
writeFileSync(join(dest, 'sentinel'), 'hi');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
try {
|
|
cloneRepo('https://example.com/repo', dest);
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(GitOperationError);
|
|
expect((e as GitOperationError).op).toBe('clone');
|
|
}
|
|
});
|
|
expect(readArgvLog().length).toBe(0); // never invoked git
|
|
rmSync(dest, { recursive: true, force: true });
|
|
});
|
|
|
|
test('throws GitOperationError when git exits non-zero', async () => {
|
|
const dest = join(FAKE_GIT_DIR, 'clone-fails');
|
|
rmSync(dest, { recursive: true, force: true });
|
|
setMode('fail');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
try {
|
|
cloneRepo('https://example.com/repo', dest);
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(GitOperationError);
|
|
expect((e as GitOperationError).op).toBe('clone');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pullRepo — fake-git harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pullRepo', () => {
|
|
test('happy path: invokes git -C path with GIT_SSRF_FLAGS + pull --ff-only', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-target');
|
|
mkdirSync(repo, { recursive: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo);
|
|
});
|
|
const argv = readArgvLog()[0];
|
|
expect(argv[0]).toBe('-C');
|
|
expect(argv[1]).toBe(repo);
|
|
expect(argv.slice(2, 2 + GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(argv).toContain('pull');
|
|
expect(argv).toContain('--ff-only');
|
|
// v0.34 fix wave: subcommand flag position assertion.
|
|
const pullIdx = argv.indexOf('pull');
|
|
expect(pullIdx).toBeGreaterThan(-1);
|
|
for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
|
|
const flagIdx = argv.indexOf(subFlag);
|
|
expect(flagIdx).toBeGreaterThan(pullIdx);
|
|
}
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('throws GitOperationError when git exits non-zero', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-fails');
|
|
mkdirSync(repo, { recursive: true });
|
|
setMode('fail');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(() => pullRepo(repo)).toThrow(GitOperationError);
|
|
});
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// local-file origin gating (#2709, security-sensitive). pullRepo is strict
|
|
// by default and only relaxes protocol.file.allow to `always` when opted-in
|
|
// AND the resolved pull origin is a local-file path physically contained
|
|
// under the trusted root. We observe the recorded argv's `-c` block
|
|
// (between the `-C <repo>` prefix and the `pull` verb).
|
|
// -------------------------------------------------------------------------
|
|
|
|
test('default (no opts): -c block is strict — protocol.file.allow=never, origin never resolved', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-strict-default');
|
|
mkdirSync(repo, { recursive: true });
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo);
|
|
});
|
|
const calls = readArgvLog();
|
|
// Strict path short-circuits origin resolution: exactly one git call.
|
|
expect(calls.length).toBe(1);
|
|
const argv = calls[0];
|
|
const flagBlock = argv.slice(2, argv.indexOf('pull'));
|
|
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(flagBlock).toContain('protocol.file.allow=never');
|
|
expect(flagBlock).not.toContain('protocol.file.allow=always');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('allowLocalFileOrigin + local origin under trusted root: -c block relaxes to GIT_SSRF_FLAGS_LOCAL (protocol.file.allow=always)', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-local-trusted');
|
|
mkdirSync(repo, { recursive: true });
|
|
// A REAL local bare-repo dir as origin, physically under the trusted root
|
|
// (realpathSync requires both to exist for the containment check).
|
|
const originPath = join(FAKE_GIT_DIR, 'local-origin.git');
|
|
mkdirSync(originPath, { recursive: true });
|
|
setMode('local-origin');
|
|
setOrigin(originPath);
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
|
|
});
|
|
const pullCall = readArgvLog().find(c => c.includes('pull'));
|
|
expect(pullCall).toBeDefined();
|
|
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
|
|
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS_LOCAL]);
|
|
expect(flagBlock).toContain('protocol.file.allow=always');
|
|
expect(flagBlock).not.toContain('protocol.file.allow=never');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
rmSync(originPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('allowLocalFileOrigin + local origin OUTSIDE trusted root: -c block stays strict (protocol.file.allow=never)', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-local-outside');
|
|
mkdirSync(repo, { recursive: true });
|
|
const originPath = join(FAKE_GIT_DIR, 'local-origin.git');
|
|
mkdirSync(originPath, { recursive: true });
|
|
// A real trusted root that does NOT contain originPath.
|
|
const outsideRoot = join(tmpdir(), `gbrain-outside-root-${process.pid}`);
|
|
mkdirSync(outsideRoot, { recursive: true });
|
|
setMode('local-origin');
|
|
setOrigin(originPath);
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: outsideRoot });
|
|
});
|
|
const pullCall = readArgvLog().find(c => c.includes('pull'));
|
|
expect(pullCall).toBeDefined();
|
|
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
|
|
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(flagBlock).toContain('protocol.file.allow=never');
|
|
expect(flagBlock).not.toContain('protocol.file.allow=always');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
rmSync(originPath, { recursive: true, force: true });
|
|
rmSync(outsideRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test('allowLocalFileOrigin + https origin: -c block stays strict (network remote never relaxed)', async () => {
|
|
const repo = join(FAKE_GIT_DIR, 'pull-https-origin');
|
|
mkdirSync(repo, { recursive: true });
|
|
setMode('local-origin');
|
|
setOrigin('https://github.com/example/repo.git');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
|
|
});
|
|
const pullCall = readArgvLog().find(c => c.includes('pull'));
|
|
expect(pullCall).toBeDefined();
|
|
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
|
|
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
|
|
expect(flagBlock).toContain('protocol.file.allow=never');
|
|
expect(flagBlock).not.toContain('protocol.file.allow=always');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('relative origin resolves against the repo dir (matching `git -C`), not process.cwd()', async () => {
|
|
// git resolves a relative file remote against the repo dir (`git -C repo
|
|
// pull`), so the containment guard must too. The relative name exists
|
|
// under repo (inside the trusted root) but NOT under process.cwd() — a
|
|
// cwd-based guard would realpath-fail and stay strict.
|
|
const repo = join(FAKE_GIT_DIR, 'pull-rel-origin');
|
|
mkdirSync(join(repo, 'rel-origin.git'), { recursive: true });
|
|
setMode('local-origin');
|
|
setOrigin('rel-origin.git');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
|
|
});
|
|
const pullCall = readArgvLog().find(c => c.includes('pull'));
|
|
expect(pullCall).toBeDefined();
|
|
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
|
|
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS_LOCAL]);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateRepoState — 6-state decision tree
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('validateRepoState', () => {
|
|
const fixtureDir = join(FAKE_GIT_DIR, 'state-fixtures');
|
|
|
|
beforeEach(() => {
|
|
rmSync(fixtureDir, { recursive: true, force: true });
|
|
mkdirSync(fixtureDir, { recursive: true });
|
|
});
|
|
|
|
test("returns 'missing' for nonexistent path", () => {
|
|
expect(validateRepoState(join(fixtureDir, 'nope'))).toBe('missing');
|
|
});
|
|
|
|
test("returns 'not-a-dir' when path is a file", () => {
|
|
const p = join(fixtureDir, 'a-file');
|
|
writeFileSync(p, 'hi');
|
|
expect(validateRepoState(p)).toBe('not-a-dir');
|
|
});
|
|
|
|
test("returns 'no-git' for directory without .git/", () => {
|
|
const p = join(fixtureDir, 'no-git-dir');
|
|
mkdirSync(p, { recursive: true });
|
|
expect(validateRepoState(p)).toBe('no-git');
|
|
});
|
|
|
|
test("returns 'corrupted' when git remote get-url fails", async () => {
|
|
const p = join(fixtureDir, 'corrupted-repo');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('fail');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p)).toBe('corrupted');
|
|
});
|
|
});
|
|
|
|
test("returns 'url-drift' when remote differs from expected", async () => {
|
|
const p = join(fixtureDir, 'drift-repo');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('url-drift');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('url-drift');
|
|
});
|
|
});
|
|
|
|
test("returns 'healthy' when remote matches expected", async () => {
|
|
const p = join(fixtureDir, 'healthy-repo');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('url-match');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('healthy');
|
|
});
|
|
});
|
|
|
|
test("returns 'healthy' when no expected URL provided (just probe)", async () => {
|
|
const p = join(fixtureDir, 'healthy-no-expect');
|
|
mkdirSync(join(p, '.git'), { recursive: true });
|
|
setMode('ok');
|
|
await withEnv({ PATH: fakePath() }, async () => {
|
|
expect(validateRepoState(p)).toBe('healthy');
|
|
});
|
|
});
|
|
});
|