/**
 * install/manifest-scanner.mjs — URL allowlist + multi-lockfile rejection (C54 R5).
 *
 * Runs BEFORE any install child is spawned. The scanner is a security gate
 * that prevents `npm install` from pulling arbitrary code via a transitive
 * `"evil": "git+ssh://hostile-host/x"` buried in a manifest or lockfile.
 *
 * Scope: parses manifests + lockfiles as text (we do NOT execute them) and
 * extracts URL-like strings. Each URL is checked against the allowlist;
 * anything outside fails the scan.
 *
 * Rejection categories (fatal — spawn refused, install_status = "failed"):
 *   - Registry URLs outside the allowlist (e.g. https://internal.corp/…)
 *   - Direct tarball URLs (https://…/archive/abc.tar.gz)
 *   - Git+SSH deps (git+ssh://… or git@github.com:…)
 *   - Local-path deps (file:…, link:…, path: "../…" in TOML)
 *   - Multi-lockfile coexistence (pnpm-lock.yaml + package-lock.json, etc.)
 *
 * Non-rejection (allowed):
 *   - Plain registry-resolved URLs matching the allowlist
 *   - git+https:// deps pointing at allowlisted hosts (GitHub / GitLab /
 *     Bitbucket) are still rejected by the git+ssh guard's stricter cousin
 *     — see `GIT_URL_PATTERN` below; we err on the side of strict here
 *     because transitive git deps are a common exfil vector.
 */
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
 * Hardcoded allowlist of trusted registries. Users can override via
 * `.aperant/config.json → task_isolation.install_registry_allowlist`.
 *
 * Match is hostname-based (not full URL): a URL is accepted if its
 * hostname equals one of these entries or ends with `.<entry>` (to
 * allow subdomain CDNs).
 */
export const DEFAULT_REGISTRY_ALLOWLIST = [
    'registry.npmjs.org',
    'registry.yarnpkg.com', // yarn's classic registry mirror
    'pypi.org',
    'files.pythonhosted.org',
    'crates.io',
    'static.crates.io',
    'index.crates.io', // sparse index
    'proxy.golang.org',
    'sum.golang.org',
    'rubygems.org',
    'packagist.org',
    'repo.packagist.org',
    'repo.maven.apache.org',
    'plugins.gradle.org',
];
// Known-bad prefixes that indicate hostile/unreviewable deps.
const GIT_SSH_PATTERN = /\bgit\+ssh:\/\//i;
const GIT_AT_PATTERN = /\bgit@([a-zA-Z0-9.-]+):/i; // git@github.com:user/repo
const GIT_URL_PATTERN = /\bgit\+https?:\/\//i; // git+http(s)://… — still rejected (too wide)
// Allow local-path deps that begin with `.` (relative imports in monorepos)
// in the `workspace:*` / `link:./packages/x` form only when they resolve
// inside the worktree. Conservative default: reject all file:/link:/path:.
// Guard the `link:` check so it doesn't match lines that just mention the
// word "link:" in prose / package names — require the prefix to sit at the
// start of a value (after `"` or whitespace).
const LOCAL_PATH_PATTERN = /["\s](file:|link:)/i;
// TOML `path = "…"` appears both at the top of a line (classic table form)
// and as a single-line inline-table form (`x = { path = "…" }`). Both should
// be rejected.
const TOML_LOCAL_PATH_PATTERN = /(^|[\s{,])path\s*=\s*["']/m;
// Tarball URLs that are not going through a registry's "files.*" CDN.
// Conservative: a URL ending in `.tar.gz`, `.tgz`, `.zip` that isn't under
// an allowlisted host is rejected. (Registries like pypi serve from
// files.pythonhosted.org; that's allowlisted.)
const TARBALL_PATTERN = /(https?:\/\/[^\s"'<>]+\.(?:tar\.gz|tgz|zip))(?=["'\s])/gi;
// Generic URL pattern — http(s) only (other schemes like `registry:` are
// registry-internal and not URLs proper).
const GENERIC_URL_PATTERN = /(https?:\/\/[^\s"'<>]+)/gi;
/**
 * Check whether a hostname matches the allowlist (exact or subdomain).
 *
 * @param {string} hostname
 * @param {string[]} allowlist
 * @returns {boolean}
 */
function isAllowlistedHost(hostname, allowlist) {
    const host = hostname.toLowerCase();
    for (const entry of allowlist) {
        const e = entry.toLowerCase();
        if (host === e)
            return true;
        if (host.endsWith(`.${e}`))
            return true;
    }
    return false;
}
/**
 * Extract URLs from a text body and classify each one. Returns a
 * structured result the caller combines across manifests/lockfiles.
 *
 * @param {string} text
 * @param {string[]} allowlist
 * @returns {{ rejections: string[], allowed: string[] }}
 */
function scanText(text, allowlist) {
    const rejections = [];
    const allowed = [];
    if (!text)
        return { rejections, allowed };
    // 1. Tarball URLs — flag before generic so we can be explicit in the reason.
    const tarMatches = text.match(TARBALL_PATTERN) || [];
    for (const tarUrl of tarMatches) {
        try {
            const u = new URL(tarUrl);
            if (!isAllowlistedHost(u.hostname, allowlist)) {
                rejections.push(`tarball URL outside allowlist: ${tarUrl}`);
            }
        }
        catch {
            rejections.push(`malformed tarball URL: ${tarUrl}`);
        }
    }
    // 2. Generic URLs — each must resolve to an allowlisted host.
    const genericMatches = text.match(GENERIC_URL_PATTERN) || [];
    for (const raw of genericMatches) {
        try {
            const u = new URL(raw);
            if (isAllowlistedHost(u.hostname, allowlist)) {
                allowed.push(raw);
            }
            else {
                rejections.push(`URL outside allowlist: ${raw}`);
            }
        }
        catch {
            rejections.push(`malformed URL: ${raw}`);
        }
    }
    // 3. git+ssh://
    if (GIT_SSH_PATTERN.test(text)) {
        const m = text.match(/git\+ssh:\/\/\S+/);
        rejections.push(`git+ssh dep rejected: ${m?.[0] ?? '(git+ssh)'}`);
    }
    // 4. git@host:user/repo pattern (npm/yarn shorthand)
    const gitAt = text.match(GIT_AT_PATTERN);
    if (gitAt) {
        rejections.push(`git@ SSH-shorthand dep rejected: ${gitAt[0]}`);
    }
    // 5. git+http(s):// — also rejected (unreviewable transitive code pull)
    if (GIT_URL_PATTERN.test(text)) {
        const m = text.match(/git\+https?:\/\/\S+/);
        rejections.push(`git+http(s) dep rejected: ${m?.[0] ?? '(git+https)'}`);
    }
    // 6. file: / link: / path: locals (JSON + TOML flavors)
    if (LOCAL_PATH_PATTERN.test(text)) {
        const m = text.match(/["\s](file:|link:)[^"\s]*/);
        rejections.push(`local-path dep rejected: ${m?.[0]?.trim() ?? '(file:/link:)'}`);
    }
    if (TOML_LOCAL_PATH_PATTERN.test(text)) {
        rejections.push('TOML `path = "…"` local-path dep rejected');
    }
    return { rejections, allowed };
}
/**
 * Normalize the effective allowlist: user override wins, otherwise use the
 * hardcoded defaults.
 *
 * @param {string[] | null | undefined} userAllowlist
 * @returns {string[]}
 */
export function resolveAllowlist(userAllowlist) {
    if (Array.isArray(userAllowlist) && userAllowlist.length > 0)
        return userAllowlist;
    return DEFAULT_REGISTRY_ALLOWLIST;
}
/**
 * Scan manifests + lockfiles for every detected ecosystem.
 *
 * @param {string} worktreePath absolute path to the worktree dir
 * @param {Array<{ecosystem: string, manifest: string, lockfile: string | null, multi_lockfile_paths: string[] | null}>} ecosystems
 * @param {{ allowlist?: string[] | null }} [opts]
 * @returns {{ ok: boolean, warnings: string[], rejections: string[], scanned_files: string[] }}
 */
export function scanManifests(worktreePath, ecosystems, opts = {}) {
    const allowlist = resolveAllowlist(opts.allowlist);
    const rejections = [];
    const warnings = [];
    const scannedFiles = [];
    if (!Array.isArray(ecosystems) || ecosystems.length === 0) {
        return { ok: true, warnings, rejections, scanned_files: scannedFiles };
    }
    for (const eco of ecosystems) {
        // Multi-lockfile rejection (R2 + R5) — mtime resolution is unreliable
        // after a git checkout.
        if (Array.isArray(eco.multi_lockfile_paths) && eco.multi_lockfile_paths.length > 1) {
            rejections.push(`multiple lockfiles present — delete all but one: [${eco.multi_lockfile_paths.join(', ')}]`);
            // Don't early-return — we want to surface all rejections in one pass.
        }
        // Scan manifest content for hostile URLs / local-path deps.
        if (eco.manifest) {
            const manifestPath = join(worktreePath, eco.manifest);
            if (existsSync(manifestPath)) {
                scannedFiles.push(manifestPath);
                try {
                    const text = readFileSync(manifestPath, 'utf-8');
                    const r = scanText(text, allowlist);
                    for (const rej of r.rejections)
                        rejections.push(`${eco.manifest}: ${rej}`);
                }
                catch (err) {
                    warnings.push(`could not read ${eco.manifest}: ${err.message}`);
                }
            }
        }
        // Scan lockfile content too — that's where transitive hostile URLs hide.
        // Binary lockfiles (bun.lockb) are skipped: their text has no URL strings
        // readable via regex. Consumers should migrate to bun.lock (text format).
        if (eco.lockfile && eco.lockfile !== 'bun.lockb') {
            const lockPath = join(worktreePath, eco.lockfile);
            if (existsSync(lockPath)) {
                scannedFiles.push(lockPath);
                try {
                    const text = readFileSync(lockPath, 'utf-8');
                    const r = scanText(text, allowlist);
                    for (const rej of r.rejections)
                        rejections.push(`${eco.lockfile}: ${rej}`);
                }
                catch (err) {
                    warnings.push(`could not read ${eco.lockfile}: ${err.message}`);
                }
            }
        }
        // Surface the ecosystem-level warning (bun.lockb-only) as a scanner
        // warning so it lands in the install log.
        if (eco.warning)
            warnings.push(`${eco.ecosystem}: ${eco.warning}`);
    }
    return {
        ok: rejections.length === 0,
        warnings,
        rejections,
        scanned_files: scannedFiles,
    };
}
//# sourceMappingURL=manifest-scanner.mjs.map