diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index fab593355..bde3f98e1 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -860,15 +860,26 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => const storagePath = getStoragePath(entry.path); await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {}); - // 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/ - const cloneDir = getCloneDir(entry.name); + // 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/. + // getCloneDir now throws on names that are not filesystem-safe (e.g. + // local repos registered with names like "my project" or "org/repo"). + // Such repos legitimately have no clone dir, so treat the rejection as + // "nothing to clean up" rather than letting it fail the delete handler. + let cloneDir: string | null = null; try { - const stat = await fs.stat(cloneDir); - if (stat.isDirectory()) { - await fs.rm(cloneDir, { recursive: true, force: true }); - } + cloneDir = getCloneDir(entry.name); } catch { - /* clone dir may not exist (local repos) */ + /* repo name not eligible for a clone dir (local repo) */ + } + if (cloneDir) { + try { + const stat = await fs.stat(cloneDir); + if (stat.isDirectory()) { + await fs.rm(cloneDir, { recursive: true, force: true }); + } + } catch { + /* clone dir may not exist */ + } } // 3. Unregister from the global registry diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 56c797d8a..b1f369dd7 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -11,16 +11,47 @@ import os from 'os'; import fs from 'fs/promises'; import { isIP } from 'net'; -/** Extract the repository name from a git URL (HTTPS or SSH). */ +/** Root directory for all cloned repositories. Targets must resolve inside this. */ +const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); + +// A valid git repository name is filesystem-safe: alphanumerics plus `. _ -`. +// Rejecting anything else (including `..`, `/`, `\`, shell metacharacters) +// guarantees getCloneDir(repoName) cannot escape CLONE_ROOT regardless of +// how the caller derived repoName. +const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; + +/** + * Extract the repository name from a git URL (HTTPS or SSH). + * + * Throws if the URL does not yield a filesystem-safe last segment. A name + * like `..` or `foo/bar` would otherwise let `getCloneDir(name)` escape the + * clone root via path traversal. + */ export function extractRepoName(url: string): string { - const cleaned = url.replace(/\/+$/, ''); - const lastSegment = cleaned.split(/[/:]/).pop() || 'unknown'; - return lastSegment.replace(/\.git$/, ''); + // Strip trailing slashes without a regex to avoid polynomial-ReDoS on + // pathological inputs like `https://x.com/y` + '/'.repeat(1e6). CodeQL's + // js/polynomial-redos flagged `/\/+$/` here. + let end = url.length; + while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--; + const cleaned = url.slice(0, end); + + const lastSegment = cleaned.split(/[/:]/).pop() || ''; + const stripped = lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment; + + if (!stripped || stripped === '.' || stripped === '..' || !REPO_NAME_PATTERN.test(stripped)) { + throw new Error('Could not extract a valid repository name from URL'); + } + return stripped; } /** Get the clone target directory for a repo name. */ export function getCloneDir(repoName: string): string { - return path.join(os.homedir(), '.gitnexus', 'repos', repoName); + // Re-validate at the boundary even though extractRepoName already checked — + // callers may pass a repoName from another source (test fixtures, scripts). + if (!repoName || repoName === '.' || repoName === '..' || !REPO_NAME_PATTERN.test(repoName)) { + throw new Error('Invalid repository name'); + } + return path.join(CLONE_ROOT, repoName); } // Cloud metadata hostnames that must never be reachable via user-supplied URLs @@ -196,32 +227,200 @@ export interface CloneProgress { message: string; } +/** + * Build the `git clone` argument list for a given URL and target directory. + * + * The `--` separator is non-negotiable: it stops git from parsing a URL that + * starts with `--` (e.g. `--upload-pack=evil`) as an option flag, which would + * otherwise execute an attacker-chosen subprocess (CodeQL + * js/second-order-command-line-injection, alerts #166/#167). + * + * Exported so the separator placement is testable without mocking spawn. + */ +export function buildCloneArgs(url: string, targetDir: string): string[] { + return ['clone', '--depth', '1', '--', url, targetDir]; +} + +/** + * Normalize a git URL into a comparable form. + * + * Two URLs are considered the same repository when their normalized forms + * are identical: lowercased hostname, no trailing `.git`, no trailing + * slashes on the path, default port stripped. Path comparison stays + * case-sensitive because that's how Git hosts treat the path component on + * the wire (case-folding GitHub's web UI is a separate convenience). + * + * Returns the original input if URL parsing fails — the caller can still + * compare with the literal string for non-URL forms (e.g. SSH `git@host:`). + */ +export function normalizeGitUrlForCompare(url: string): string { + // Strip trailing slashes and a trailing `.git` for both URL and SSH forms. + let trimmed = url; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '/') { + trimmed = trimmed.slice(0, -1); + } + if (trimmed.endsWith('.git')) trimmed = trimmed.slice(0, -4); + + try { + const parsed = new URL(trimmed); + parsed.hostname = parsed.hostname.toLowerCase(); + // strip default ports + if ( + (parsed.protocol === 'https:' && parsed.port === '443') || + (parsed.protocol === 'http:' && parsed.port === '80') + ) { + parsed.port = ''; + } + // Strip credentials — never material to repo identity, and including + // them would let two equivalent URLs (with/without basic auth) compare + // unequal. + parsed.username = ''; + parsed.password = ''; + // Recompose without trailing slash on the path. + let pathname = parsed.pathname; + while (pathname.length > 1 && pathname[pathname.length - 1] === '/') { + pathname = pathname.slice(0, -1); + } + parsed.pathname = pathname; + return `${parsed.protocol}//${parsed.hostname}${parsed.port ? ':' + parsed.port : ''}${parsed.pathname}`; + } catch { + // Non-URL forms (e.g. `git@github.com:owner/repo`) — return the trimmed + // form lowercased on the hostname-ish prefix. SSH-form normalization + // is best-effort; exact-string compare is sufficient for the threat + // model (mismatched origins still differ at the literal level). + return trimmed.toLowerCase(); + } +} + +/** + * Read `remote.origin.url` from an existing clone using `git config --get`. + * + * Returns `null` if the config key is absent, the spawn fails, or the + * directory isn't a git repository. The caller decides what a missing + * remote means for its threat model — for cloneOrPull, a missing remote + * on an existing clone is treated as a refuse-to-pull condition. + */ +export function getRemoteOriginUrl(cwd: string): Promise { + return new Promise((resolve) => { + const proc = spawn('git', ['config', '--get', 'remote.origin.url'], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + let stdout = ''; + proc.stdout.on('data', (chunk: Buffer) => { + stdout += chunk; + }); + proc.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()); + } else { + resolve(null); + } + }); + proc.on('error', () => resolve(null)); + }); +} + +/** + * Verify that an existing clone's `remote.origin.url` matches the requested + * URL (after normalization). Throws on mismatch or missing remote. + * + * Closes the wrong-repo silent-analysis vector that Codex's adversarial + * review on PR #1325 surfaced: clone dirs are keyed by URL basename, so a + * request for `https://gitlab.example/attacker/repo.git` would otherwise + * collide with an existing `~/.gitnexus/repos/repo` cloned from a different + * origin and `git pull --ff-only` would silently succeed against the wrong + * remote. + * + * Exported so the comparison logic is testable in isolation against any + * tmpdir-based fixture, without needing to populate CLONE_ROOT. + */ +export async function assertRemoteMatchesRequestedUrl( + targetDir: string, + requestedUrl: string, +): Promise { + const remoteUrl = await getRemoteOriginUrl(targetDir); + if (remoteUrl === null) { + throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`); + } + if (normalizeGitUrlForCompare(remoteUrl) !== normalizeGitUrlForCompare(requestedUrl)) { + throw new Error( + `Existing clone at ${targetDir} has remote ${remoteUrl}, not the requested URL ${requestedUrl}`, + ); + } +} + /** * Clone or pull a git repository. * If targetDir doesn't exist: git clone --depth 1 - * If targetDir exists with .git: git pull --ff-only + * If targetDir exists with .git: git pull --ff-only (after verifying the + * existing clone's remote.origin matches the requested URL). + * + * Security: + * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The + * path.relative containment barrier below is the inline canonical idiom + * CodeQL's js/path-injection sanitizer recognizes. + * - validateGitUrl runs unconditionally on the requested URL — both the + * clone path and the pull path. An earlier shape only validated on the + * clone branch; an existing clone with the same basename let an + * attacker's URL skip the SSRF / scheme / private-IP checks (Codex + * adversarial review on PR #1325). + * - When the target already has `.git`, the existing clone's + * remote.origin.url is fetched and compared (normalized) to the + * requested URL. Refuses to pull if they differ — this closes the + * wrong-repo silent-analysis vector where two URLs sharing a basename + * would collide on the same on-disk clone dir. + * - The git URL is passed after a `--` separator so a value beginning with + * `--` (e.g. `--upload-pack=evil`) cannot be interpreted as a git option + * (CodeQL js/second-order-command-line-injection). */ export async function cloneOrPull( url: string, targetDir: string, onProgress?: (progress: CloneProgress) => void, ): Promise { - const exists = await fs.access(path.join(targetDir, '.git')).then( + // Containment barrier — inline with the canonical path.relative idiom so + // CodeQL recognizes the sanitizer at every following filesystem and + // subprocess sink. The same `safeTarget` is used for every downstream + // path operation — no reassignment that the analyzer could lose track of. + // + // Limitation: this is a lexical containment check, not a realpath check. + // If an attacker can place a symlink under CLONE_ROOT pointing outside it, + // the lexical check passes but the clone lands at the symlink target. That + // requires pre-existing local write access to CLONE_ROOT, so the threat + // model considers it out of scope; CodeQL js/path-injection accepts the + // lexical form. Tracked as a follow-up if defense-in-depth is needed. + const safeTarget = path.resolve(targetDir); + const rel = path.relative(CLONE_ROOT, safeTarget); + if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`); + } + + // Always validate the requested URL — the prior shape only ran this in + // the clone branch, leaving the pull branch as an SSRF / blocked-host + // bypass when an existing clone shared the basename of an attacker URL. + validateGitUrl(url); + + const exists = await fs.access(path.join(safeTarget, '.git')).then( () => true, () => false, ); if (exists) { + // Confirm the existing clone is actually the same repository the caller + // requested. Without this check, a pull would silently succeed against + // whatever remote the dir was originally cloned from. + await assertRemoteMatchesRequestedUrl(safeTarget, url); onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' }); - await runGit(['pull', '--ff-only'], targetDir); + await runGit(['pull', '--ff-only'], safeTarget); } else { - validateGitUrl(url); - await fs.mkdir(path.dirname(targetDir), { recursive: true }); + await fs.mkdir(path.dirname(safeTarget), { recursive: true }); onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` }); - await runGit(['clone', '--depth', '1', url, targetDir]); + await runGit(buildCloneArgs(url, safeTarget)); } - return targetDir; + return safeTarget; } function runGit(args: string[], cwd?: string): Promise { diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 6b8e74556..18ac8e9d6 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -1,5 +1,18 @@ -import { describe, it, expect } from 'vitest'; -import { extractRepoName, getCloneDir, validateGitUrl } from '../../src/server/git-clone.js'; +import { afterAll, beforeAll, describe, it, expect } from 'vitest'; +import { + extractRepoName, + getCloneDir, + validateGitUrl, + cloneOrPull, + buildCloneArgs, + normalizeGitUrlForCompare, + assertRemoteMatchesRequestedUrl, + getRemoteOriginUrl, +} from '../../src/server/git-clone.js'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; +import { spawn } from 'node:child_process'; describe('git-clone', () => { describe('extractRepoName', () => { @@ -22,6 +35,48 @@ describe('git-clone', () => { it('handles nested paths', () => { expect(extractRepoName('https://gitlab.com/group/subgroup/repo.git')).toBe('repo'); }); + + it('rejects URLs whose last segment is "..": prevents getCloneDir traversal escape', () => { + // Without the safe-name pattern, a URL ending in `/..` would yield + // `getCloneDir('..')` = `~/.gitnexus/repos/..` = `~/.gitnexus/`, breaking + // out of the intended clone root. + expect(() => extractRepoName('https://github.com/owner/repo:..')).toThrow( + 'valid repository name', + ); + expect(() => extractRepoName('https://example.com/foo:..')).toThrow('valid repository name'); + }); + + it('rejects URLs that yield a single dot', () => { + expect(() => extractRepoName('https://example.com/foo:.')).toThrow('valid repository name'); + }); + + it('rejects URLs with shell metacharacters in the last segment', () => { + // The split on /[/:]/ does not split on backslashes or other shell chars, + // so a name like `repo;rm -rf /` would slip through without the pattern. + expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow( + 'valid repository name', + ); + expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow( + 'valid repository name', + ); + }); + + it('rejects empty input', () => { + expect(() => extractRepoName('')).toThrow('valid repository name'); + }); + + it('handles many trailing slashes without polynomial-time blowup', () => { + // Pathological input the previous /\\/+$/ regex was flagged for + // (CodeQL js/polynomial-redos). The string-loop replacement is O(n). + const url = 'https://example.com/repo' + '/'.repeat(10000); + const start = performance.now(); + expect(extractRepoName(url)).toBe('repo'); + const elapsedMs = performance.now() - start; + // Threshold of 500ms is intentionally loose to absorb slow CI runners + // while still catching a true polynomial regression (which would take + // multiple seconds on 10k slashes). + expect(elapsedMs).toBeLessThan(500); + }); }); describe('getCloneDir', () => { @@ -31,6 +86,26 @@ describe('git-clone', () => { expect(dir).toMatch(/repos/); expect(dir).toContain('my-repo'); }); + + it('rejects ".." to prevent path-traversal escape from the clone root', () => { + expect(() => getCloneDir('..')).toThrow('Invalid repository name'); + expect(() => getCloneDir('.')).toThrow('Invalid repository name'); + expect(() => getCloneDir('')).toThrow('Invalid repository name'); + }); + + it('rejects names containing path separators', () => { + expect(() => getCloneDir('foo/bar')).toThrow('Invalid repository name'); + expect(() => getCloneDir('foo\\bar')).toThrow('Invalid repository name'); + }); + + it('returned path is always a direct child of the clone root', () => { + const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); + const dir = getCloneDir('my-repo'); + const rel = path.relative(cloneRoot, path.resolve(dir)); + // path.relative from the parent to the child must be just the child name — + // no .. and no path separators inside. + expect(rel).toBe('my-repo'); + }); }); describe('validateGitUrl', () => { @@ -187,4 +262,268 @@ describe('git-clone', () => { expect(() => validateGitUrl('http://0.0.0.0/repo.git')).toThrow('private/internal'); }); }); + + describe('buildCloneArgs', () => { + // Closes the test-coverage gap that PR #1325 review (HIGH finding 1) + // identified for CodeQL js/second-order-command-line-injection alerts + // #166/#167. The barrier these tests guard is the `--` separator that + // prevents an option-like URL from being parsed by git as a flag. + it('places `--` before the URL', () => { + const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target'); + const dashDashIdx = args.indexOf('--'); + const urlIdx = args.indexOf('https://github.com/owner/repo.git'); + expect(dashDashIdx).toBeGreaterThan(-1); + expect(urlIdx).toBeGreaterThan(dashDashIdx); + }); + + it('treats an option-like URL as a positional argument, not a flag', () => { + // The exact mitigation for second-order-command-line-injection: a URL + // beginning with `--` must appear after the `--` separator so git + // refuses to interpret it as `--upload-pack=evil`. + const args = buildCloneArgs('--upload-pack=evil', '/safe/target'); + const dashDashIdx = args.indexOf('--'); + const urlIdx = args.indexOf('--upload-pack=evil'); + expect(dashDashIdx).toBeGreaterThan(-1); + expect(urlIdx).toBeGreaterThan(dashDashIdx); + // And targetDir comes after URL, also positional. + expect(args.indexOf('/safe/target')).toBeGreaterThan(urlIdx); + }); + + it('preserves --depth 1 for shallow clones', () => { + const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target'); + const depthIdx = args.indexOf('--depth'); + expect(depthIdx).toBeGreaterThan(-1); + expect(args[depthIdx + 1]).toBe('1'); + // --depth must be before the `--` separator (it's an option, not a positional). + expect(depthIdx).toBeLessThan(args.indexOf('--')); + }); + }); + + describe('cloneOrPull — containment barrier', () => { + // Closes the test-coverage gap that PR #1325 review (HIGH finding 1) + // identified for CodeQL js/path-injection alerts #176/#177/#178. The + // barrier these tests guard is the path.relative containment check at + // the entry of cloneOrPull, which must reject any targetDir not strictly + // inside CLONE_ROOT before any filesystem or subprocess sink. + // + // These tests do NOT mock spawn — the barrier throws synchronously + // before git is invoked, so the rejection is observable directly. + const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); + + it('rejects an absolute target outside CLONE_ROOT', async () => { + await expect(cloneOrPull('https://github.com/a/b.git', '/etc/passwd')).rejects.toThrow( + 'Clone target must be a subdirectory', + ); + }); + + it('rejects CLONE_ROOT itself (the rel === "" branch)', async () => { + await expect(cloneOrPull('https://github.com/a/b.git', cloneRoot)).rejects.toThrow( + 'Clone target must be a subdirectory', + ); + }); + + it('rejects a parent-directory traversal attempt', async () => { + await expect( + cloneOrPull('https://github.com/a/b.git', path.join(cloneRoot, '..', 'escape')), + ).rejects.toThrow('Clone target must be a subdirectory'); + }); + + it('rejects a sibling directory with a common prefix (CLONE_ROOT-evil)', async () => { + // Classic startsWith(root + sep) pitfall: '/x/repos' does not catch + // '/x/repos-evil/...'. The path.relative idiom does, and the test + // documents that property at the cloneOrPull boundary. + await expect(cloneOrPull('https://github.com/a/b.git', cloneRoot + '-evil')).rejects.toThrow( + 'Clone target must be a subdirectory', + ); + }); + + // Closes the SSRF-bypass vector that Codex's adversarial review on + // PR #1325 surfaced: validateGitUrl was only called in the clone + // branch. An attacker URL that shared a basename with an existing + // clone would skip the SSRF check entirely on the pull path. + // + // The barrier-pass-but-validateGitUrl-throw case here works because + // cloneOrPull validates the URL after the containment check and before + // the existence probe, so the rejection fires regardless of whether + // the target dir exists on disk. + it('rejects URLs that fail validateGitUrl even when the target shape is valid', async () => { + const fakeTarget = path.join(cloneRoot, 'name-that-does-not-exist'); + await expect(cloneOrPull('http://127.0.0.1/repo.git', fakeTarget)).rejects.toThrow( + 'private/internal', + ); + await expect(cloneOrPull('http://localhost/repo.git', fakeTarget)).rejects.toThrow( + 'private/internal', + ); + await expect(cloneOrPull('file:///etc/passwd', fakeTarget)).rejects.toThrow( + 'Only https:// and http://', + ); + }); + }); + + describe('normalizeGitUrlForCompare', () => { + it('strips trailing .git', () => { + expect(normalizeGitUrlForCompare('https://github.com/owner/repo.git')).toBe( + normalizeGitUrlForCompare('https://github.com/owner/repo'), + ); + }); + + it('strips trailing slashes', () => { + expect(normalizeGitUrlForCompare('https://github.com/owner/repo/')).toBe( + normalizeGitUrlForCompare('https://github.com/owner/repo'), + ); + expect(normalizeGitUrlForCompare('https://github.com/owner/repo///')).toBe( + normalizeGitUrlForCompare('https://github.com/owner/repo'), + ); + }); + + it('lowercases the hostname but preserves path case', () => { + expect(normalizeGitUrlForCompare('https://GitHub.com/owner/Repo.git')).toBe( + normalizeGitUrlForCompare('https://github.com/owner/Repo'), + ); + // Different path case → distinct repos (hosts treat path as case-sensitive on the wire) + expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe( + normalizeGitUrlForCompare('https://github.com/owner/REPO'), + ); + }); + + it('strips default ports', () => { + expect(normalizeGitUrlForCompare('https://github.com:443/owner/repo')).toBe( + normalizeGitUrlForCompare('https://github.com/owner/repo'), + ); + expect(normalizeGitUrlForCompare('http://github.com:80/owner/repo')).toBe( + normalizeGitUrlForCompare('http://github.com/owner/repo'), + ); + }); + + it('preserves non-default ports', () => { + expect(normalizeGitUrlForCompare('https://git.corp:8443/owner/repo')).not.toBe( + normalizeGitUrlForCompare('https://git.corp/owner/repo'), + ); + }); + + it('strips userinfo (basic auth) so equivalent URLs compare equal', () => { + expect(normalizeGitUrlForCompare('https://user:pass@github.com/owner/repo.git')).toBe( + normalizeGitUrlForCompare('https://github.com/owner/repo'), + ); + }); + + it('treats different hosts as distinct', () => { + expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe( + normalizeGitUrlForCompare('https://gitlab.com/owner/repo'), + ); + }); + + it('treats different paths on the same host as distinct', () => { + expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe( + normalizeGitUrlForCompare('https://github.com/attacker/repo'), + ); + }); + }); + + describe('assertRemoteMatchesRequestedUrl', () => { + // Closes the wrong-repo silent-analysis vector that Codex's adversarial + // review on PR #1325 surfaced. Tests use a tmpdir-based fixture + // (anywhere on disk — independent of CLONE_ROOT) so the helper can be + // exercised without polluting the user's actual clone root. + let fixtureDir: string; + + beforeAll(async () => { + fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-remote-match-')); + // git init + set remote.origin.url. We can't call git init via runGit + // since it's private; spawn directly. + await new Promise((resolve, reject) => { + const proc = spawn('git', ['init', '--quiet'], { cwd: fixtureDir, stdio: 'ignore' }); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)), + ); + proc.on('error', reject); + }); + await new Promise((resolve, reject) => { + const proc = spawn( + 'git', + ['config', 'remote.origin.url', 'https://github.com/legitorg/myproject.git'], + { cwd: fixtureDir, stdio: 'ignore' }, + ); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`git config exit ${code}`)), + ); + proc.on('error', reject); + }); + }); + + afterAll(async () => { + await fs.rm(fixtureDir, { recursive: true, force: true }); + }); + + it('accepts the requested URL when it matches the configured remote', async () => { + await expect( + assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject.git'), + ).resolves.toBeUndefined(); + }); + + it('accepts equivalent forms (with/without .git, trailing slash, default port)', async () => { + await expect( + assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject'), + ).resolves.toBeUndefined(); + await expect( + assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject/'), + ).resolves.toBeUndefined(); + await expect( + assertRemoteMatchesRequestedUrl( + fixtureDir, + 'https://github.com:443/legitorg/myproject.git', + ), + ).resolves.toBeUndefined(); + }); + + // The exact wrong-repo vector from Codex's review: + // existing clone → github.com/legitorg/myproject + // request URL → gitlab.example/attacker/myproject + // Both share the basename 'myproject'. Without this check, the pull + // would succeed and analysis would return wrong-repo data. + it('rejects a different host with the same basename', async () => { + await expect( + assertRemoteMatchesRequestedUrl( + fixtureDir, + 'https://gitlab.example/attacker/myproject.git', + ), + ).rejects.toThrow('not the requested URL'); + }); + + it('rejects a different owner on the same host', async () => { + await expect( + assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/attacker/myproject.git'), + ).rejects.toThrow('not the requested URL'); + }); + + it('rejects when the directory has no remote.origin', async () => { + const noRemoteDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-no-remote-')); + try { + await new Promise((resolve, reject) => { + const proc = spawn('git', ['init', '--quiet'], { cwd: noRemoteDir, stdio: 'ignore' }); + proc.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)), + ); + proc.on('error', reject); + }); + await expect( + assertRemoteMatchesRequestedUrl(noRemoteDir, 'https://github.com/owner/repo.git'), + ).rejects.toThrow('no remote.origin'); + } finally { + await fs.rm(noRemoteDir, { recursive: true, force: true }); + } + }); + }); + + describe('getRemoteOriginUrl', () => { + it('returns null for a directory that is not a git repository', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-not-git-')); + try { + const result = await getRemoteOriginUrl(tmp); + expect(result).toBeNull(); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + }); });