/**
 * Git Clone Utility
 *
 * Shallow-clones repositories into ~/.gitnexus/repos/{name}/.
 * If already cloned, does git pull instead.
 */
import { spawn } from 'child_process';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
import { logger } from '../core/logger.js';
import { parseRepoNameFromUrl } from '../storage/git.js';
/** 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) {
    const name = parseRepoNameFromUrl(url);
    if (!name ||
        name === '.' ||
        name === '..' ||
        name === 'unknown' ||
        !REPO_NAME_PATTERN.test(name)) {
        throw new Error('Could not extract a valid repository name from URL');
    }
    return name;
}
/** Get the clone target directory for a repo name. */
export function getCloneDir(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
const BLOCKED_HOSTNAMES = new Set([
    'localhost',
    'metadata.google.internal',
    'metadata.azure.com',
    'metadata.internal',
]);
/**
 * Validate a git URL to prevent SSRF attacks.
 * Only allows https:// and http:// schemes. Blocks private/internal addresses,
 * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
 */
export function validateGitUrl(url) {
    let parsed;
    try {
        parsed = new URL(url);
    }
    catch {
        throw new Error('Invalid URL');
    }
    if (!['https:', 'http:'].includes(parsed.protocol)) {
        throw new Error('Only https:// and http:// git URLs are allowed');
    }
    const host = parsed.hostname.toLowerCase();
    // Block known dangerous hostnames (cloud metadata services)
    if (BLOCKED_HOSTNAMES.has(host)) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
    let normalizedHost = host;
    if (host.startsWith('[') && host.endsWith(']')) {
        normalizedHost = host.slice(1, -1);
    }
    // Check if this is an IPv6 address
    // Use manual colon detection as fallback since isIP may return 0 for some
    // normalized IPv6 forms (e.g. ::ffff:7f00:1)
    const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
    if (isIPv6) {
        assertNotPrivateIPv6(normalizedHost);
        return;
    }
    // Check if this is an IPv4 address (including numeric encodings)
    if (isIP(normalizedHost) === 4) {
        assertNotPrivateIPv4(normalizedHost);
        return;
    }
    // For non-IP hostnames, check for numeric IP tricks
    // Decimal encoding: 2130706433 = 127.0.0.1
    // Hex encoding: 0x7f000001 = 127.0.0.1
    if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // Standard IPv4 regex checks for dotted notation
    if (/^127\./.test(host) ||
        /^10\./.test(host) ||
        /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
        /^192\.168\./.test(host) ||
        /^169\.254\./.test(host) ||
        /^0\./.test(host) ||
        host === '0.0.0.0' ||
        /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
        /^198\.1[89]\./.test(host)) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
}
function assertNotPrivateIPv6(ip) {
    // Expand common compressed forms for comparison
    const lower = ip.toLowerCase();
    // IPv6 loopback
    if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // Unspecified address
    if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
    if (lower.startsWith('fc') || lower.startsWith('fd')) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // IPv6 link-local (fe80::/10)
    if (lower.startsWith('fe80') ||
        lower.startsWith('fe8') ||
        lower.startsWith('fe9') ||
        lower.startsWith('fea') ||
        lower.startsWith('feb')) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
    // Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
    if (lower.startsWith('::ffff:')) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // Also catch the expanded form: 0:0:0:0:0:ffff:
    if (lower.includes(':ffff:')) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
    // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
    // is hidden in the last 32 bits without the ::ffff: marker, so the check
    // above misses it. The form is still routable to the embedded IPv4 on most
    // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
    if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
    // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
    // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
    // e.g. 64:ff9b::7f00:1 → 127.0.0.1.
    // The check intentionally covers the full 64:ff9b::/32 block (broader than
    // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
    // blocking the whole prefix is defensively sound and prevents a narrower
    // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
    // future translation assignment.
    if (lower.startsWith('64:ff9b:')) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
    // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
    // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
    // protocol was deprecated by RFC 7526 and the public relay anycast
    // (192.88.99.1) has been retired, so broad-blocking the prefix has near-
    // zero false-positive cost while closing the IPv4-embedded bypass.
    // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
    // impractical and is out of scope here.
    if (lower.startsWith('2002:')) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
}
function assertNotPrivateIPv4(ip) {
    const parts = ip.split('.').map(Number);
    const [a, b] = parts;
    if (a === 127 ||
        a === 10 ||
        (a === 172 && b >= 16 && b <= 31) ||
        (a === 192 && b === 168) ||
        (a === 169 && b === 254) ||
        a === 0 ||
        (a === 100 && b >= 64 && b <= 127) ||
        (a === 198 && (b === 18 || b === 19))) {
        throw new Error('Cloning from private/internal addresses is not allowed');
    }
}
/**
 * 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, targetDir) {
    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) {
    // 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) {
    return new Promise((resolve) => {
        const proc = spawn('git', ['config', '--get', 'remote.origin.url'], {
            cwd,
            stdio: ['ignore', 'pipe', 'pipe'],
            windowsHide: true,
            env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
        });
        let stdout = '';
        proc.stdout.on('data', (chunk) => {
            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, requestedUrl) {
    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 (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, targetDir, onProgress) {
    // 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 code path where the repo was cloned. Now it runs unconditionally,
    // preventing SSRF / blocked-host bypasses even when targetDir already exists.
    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'], safeTarget);
    }
    else {
        await fs.mkdir(path.dirname(safeTarget), { recursive: true });
        onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
        await runGit(buildCloneArgs(url, safeTarget));
    }
    return safeTarget;
}
function runGit(args, cwd) {
    return new Promise((resolve, reject) => {
        const proc = spawn('git', args, {
            cwd,
            stdio: ['ignore', 'pipe', 'pipe'],
            windowsHide: true,
            env: {
                ...process.env,
                // Prevent git from prompting for credentials (hangs the process)
                GIT_TERMINAL_PROMPT: '0',
                // Ensure no credential helper tries to open a GUI prompt
                GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true',
            },
        });
        let stderr = '';
        proc.stderr.on('data', (chunk) => {
            stderr += chunk;
        });
        proc.on('close', (code) => {
            if (code === 0)
                resolve();
            else {
                // Log full stderr internally but don't expose it to API callers (SSRF mitigation)
                if (stderr.trim())
                    logger.error(`git ${args[0]} stderr: ${stderr.trim()}`);
                reject(new Error(`git ${args[0]} failed (exit code ${code})`));
            }
        });
        proc.on('error', (err) => {
            reject(new Error(`Failed to spawn git: ${err.message}`));
        });
    });
}
