import { realpathSync } from "node:fs";
import path from "node:path";
export class RepoPathPolicyError extends Error {
    reasonCode;
    constructor(reasonCode) {
        // The message is intentionally the reason code only: never the path.
        super(reasonCode);
        this.name = "RepoPathPolicyError";
        this.reasonCode = reasonCode;
    }
}
/**
 * Enforces that every working-copy / clone target the bridge touches is an
 * absolute path strictly contained within an operator-configured allow-list of
 * roots. There is no implicit default root: an empty allow-list fails closed,
 * so arbitrary system paths (`/`, `~/.ssh`, ...) can never be written or read.
 *
 * Two checks:
 *  - {@link resolveCloneTarget} — for a path that does NOT yet exist (clone
 *    destination). Validates the lexical, normalized path is inside a root and
 *    is a strict subdirectory of it. The nearest existing ancestor is realpath'd
 *    so a symlinked ancestor cannot smuggle the target outside a root.
 *  - {@link assertExistingWorkingCopy} — for a path that already exists (locate).
 *    Validates the fully realpath-resolved path (symlinks followed) is inside a
 *    root, defeating a symlink whose target escapes.
 */
export class RepoPathPolicy {
    #roots;
    constructor(configuredRoots) {
        const roots = [];
        for (const raw of configuredRoots) {
            const trimmed = typeof raw === "string" ? raw.trim() : "";
            if (!trimmed || !path.isAbsolute(trimmed))
                continue;
            const resolved = path.resolve(trimmed);
            const real = safeRealpath(resolved) ?? resolved;
            if (!roots.includes(real))
                roots.push(real);
        }
        this.#roots = roots;
    }
    hasAllowedRoots() {
        return this.#roots.length > 0;
    }
    allowedRoots() {
        return [...this.#roots];
    }
    /**
     * Resolve + validate a clone destination that does not yet exist. Returns the
     * normalized absolute target. Throws {@link RepoPathPolicyError} otherwise.
     */
    resolveCloneTarget(candidate) {
        this.#assertConfigured();
        if (typeof candidate !== "string" || !path.isAbsolute(candidate)) {
            throw new RepoPathPolicyError("repo-path-not-absolute");
        }
        const normalized = path.resolve(candidate);
        // The clone leaf may not exist yet, so canonicalize the path through the
        // realpath of its nearest existing ancestor. This both normalizes the root
        // form (macOS /var → /private/var) AND defeats a symlinked ANCESTOR that
        // would otherwise smuggle the target outside an allowed root.
        const existingAncestor = nearestExistingAncestor(normalized);
        const realAncestor = existingAncestor
            ? safeRealpath(existingAncestor)
            : null;
        if (!existingAncestor || !realAncestor) {
            throw new RepoPathPolicyError("repo-path-outside-allowed-roots");
        }
        const tail = path.relative(existingAncestor, normalized);
        const realTarget = tail.length === 0 ? realAncestor : path.join(realAncestor, tail);
        if (!this.#isWithinAnyRoot(realTarget, false)) {
            throw new RepoPathPolicyError("repo-path-outside-allowed-roots");
        }
        return realTarget;
    }
    /**
     * Validate an already-existing working copy (locate flow). Follows symlinks
     * fully (realpath) and requires the real path to be inside an allowed root.
     * Returns the realpath-resolved absolute path.
     */
    assertExistingWorkingCopy(candidate) {
        this.#assertConfigured();
        if (typeof candidate !== "string" || !path.isAbsolute(candidate)) {
            throw new RepoPathPolicyError("repo-path-not-absolute");
        }
        const real = safeRealpath(path.resolve(candidate));
        if (!real) {
            throw new RepoPathPolicyError("repo-path-not-found");
        }
        if (!this.#isWithinAnyRoot(real, true)) {
            throw new RepoPathPolicyError("repo-path-outside-allowed-roots");
        }
        return real;
    }
    #assertConfigured() {
        if (this.#roots.length === 0) {
            throw new RepoPathPolicyError("repo-roots-not-configured");
        }
    }
    #isWithinAnyRoot(target, allowEqual) {
        return this.#roots.some((root) => allowEqual
            ? root === target || isStrictlyWithin(root, target)
            : isStrictlyWithin(root, target));
    }
}
/** True when `target` is a strict subpath of `root` (not equal to it). */
function isStrictlyWithin(root, target) {
    const relative = path.relative(root, target);
    return (relative.length > 0 &&
        !relative.startsWith("..") &&
        !path.isAbsolute(relative));
}
function nearestExistingAncestor(target) {
    let current = path.dirname(target);
    // Walk up until an existing directory is found or the filesystem root.
    while (true) {
        if (safeRealpath(current))
            return current;
        const parent = path.dirname(current);
        if (parent === current)
            return null;
        current = parent;
    }
}
function safeRealpath(target) {
    try {
        return realpathSync(target);
    }
    catch {
        return null;
    }
}
