/** Filesystem containment and safe reads for project learnings. */
import * as fse from "fs-extra";
import { lstat, open, realpath } from "node:fs/promises";
import * as path from "node:path";
import { LEARNINGS_CONTRACT } from "./learnings-contract.js";
import { conflictMarkerError, findConflictMarkerInBytes, } from "./learnings-document.js";
import { eagerContextRejection, findEagerContextSurface, } from "./learnings-location.js";
/**
 * Resolve the configured target without permitting project-root escape, and
 * without permitting an auto-loaded rules tree.
 *
 * The eager-tree arm is the write boundary's own guard, deliberately duplicated
 * from `learnings.file` config validation rather than delegated to it. Lisa
 * 2.232.0 scaffolded the ledger into `.claude/rules/` and every downstream
 * project happily wrote there: the raw ledger entered eager context in every
 * session, and a merge-hostile file sat where nothing watched it until 19
 * captured learnings vanished in a silent merge. Every writer — ledger,
 * overflow, apply's relocation — funnels through this function, so failing
 * loudly HERE means no future caller can reintroduce that placement by
 * synthesizing a path that never passed through config validation.
 * @param projectRoot - Host project root
 * @param relativeFile - Configured project-relative file
 * @returns Resolved root and target paths
 */
export function resolveSafeLearningTarget(projectRoot, relativeFile) {
    const root = path.resolve(projectRoot);
    const target = path.resolve(root, relativeFile);
    if (target === root || !target.startsWith(`${root}${path.sep}`)) {
        throw new Error("Unsafe projectRulesFile: learnings path escapes project root");
    }
    // Re-derive the relative path from the RESOLVED target rather than trusting
    // the caller's string: `.lisa/../.claude/rules/x.md` is an eager-tree write
    // that no prefix test on the raw input would catch.
    const surface = findEagerContextSurface(path.relative(root, target));
    if (surface !== undefined) {
        throw new Error(`Refusing to use ${relativeFile} as a learnings ledger: ${eagerContextRejection(surface)}`);
    }
    return { root, target };
}
/**
 * Read only a bounded regular file from the inode that was safety-checked.
 * @param target - Resolved learnings file path
 * @returns Existing content, or undefined when the file does not exist
 */
export async function readExistingLearnings(target) {
    try {
        const beforeOpen = await lstat(target);
        if (!beforeOpen.isFile()) {
            throw new Error("Unsafe project learnings path: target is not a regular file");
        }
        const handle = await open(target, "r");
        try {
            const opened = await handle.stat();
            if (!opened.isFile() ||
                opened.dev !== beforeOpen.dev ||
                opened.ino !== beforeOpen.ino) {
                throw new Error("Unsafe project learnings path: target changed during open");
            }
            if (opened.size > LEARNINGS_CONTRACT.maxTokens) {
                // Diagnose conflict corruption before reporting size. A conflicted
                // merge roughly doubles the ledger, so this branch is exactly where a
                // real corruption lands — reporting "exceeds maxTokens" here would
                // send the writer's caller off shortening entries instead of
                // recompacting the duplicated block.
                await assertNoConflictMarkerPrefix(handle);
                throw new Error(`Project learnings payload exceeds maxTokens ${LEARNINGS_CONTRACT.maxTokens}`);
            }
            return handle.readFile({ encoding: "utf8" });
        }
        finally {
            await handle.close();
        }
    }
    catch (error) {
        if (error.code === "ENOENT") {
            return undefined;
        }
        throw error;
    }
}
/** A locked ledger transaction found its read-image already overwritten. */
export class LearningsConcurrentWriteError extends Error {
    /**
     * Build the operator-readable concurrent-write diagnosis.
     * @param target - Resolved learnings file path
     */
    constructor(target) {
        super(`Refusing to publish project learnings: ${target} changed after this ` +
            `write read it, so publishing would erase another writer's entry. ` +
            `Nothing was written and no entry was lost — retry the capture. If ` +
            `this repeats, the ledger lock is not holding.`);
        this.name = "LearningsConcurrentWriteError";
    }
}
/**
 * Prove the ledger still holds the bytes this transaction read before letting
 * an atomic rename publish over them.
 *
 * The ledger is read-modify-write, so a writer that publishes over an image it
 * did not read erases whatever landed in between. The lock is what prevents
 * that, and this is the arm that makes a lock failure LOUD: the whole class of
 * bugs here (CodySwannGT/lisa#2488, and the 19 entries lost to a merge before
 * it) is defined by nothing noticing. This detects an interloper rather than
 * excluding one — it is the second line, not the lock — but it converts a
 * silent lost learning into a failed capture the caller can retry.
 * @param target - Resolved learnings file path
 * @param preImage - Content this transaction read under the lock
 */
export async function assertLearningsUnchanged(target, preImage) {
    const current = await readExistingLearnings(target);
    if (current !== preImage) {
        throw new LearningsConcurrentWriteError(target);
    }
}
/**
 * Throw the shared conflict diagnosis when an over-budget file is corrupted.
 *
 * Reads only a bounded prefix from the already-verified handle, so an oversized
 * ledger is diagnosed without ever being fully loaded.
 * @param handle - Open handle to the verified regular file
 */
async function assertNoConflictMarkerPrefix(handle) {
    const buffer = Buffer.allocUnsafe(LEARNINGS_CONTRACT.maxTokens + 1);
    const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
    const conflictLine = findConflictMarkerInBytes(buffer.subarray(0, bytesRead));
    if (conflictLine !== undefined) {
        throw conflictMarkerError(conflictLine);
    }
}
/**
 * Reject parent-directory symlinks that resolve outside the project root.
 * @param root - Resolved project root
 * @param parent - Target parent directory
 */
export async function assertSafeLearningParents(root, parent) {
    const realRoot = await realpath(root);
    const cursor = await findExistingAncestor(parent);
    const realParent = await realpath(cursor);
    if (realParent !== realRoot &&
        !realParent.startsWith(`${realRoot}${path.sep}`)) {
        throw new Error("Unsafe project learnings path: parent escapes project root");
    }
}
/**
 * Find the nearest existing ancestor without mutable traversal state.
 * @param candidate - Starting filesystem path
 * @returns Nearest existing ancestor
 */
async function findExistingAncestor(candidate) {
    if (await fse.pathExists(candidate)) {
        return candidate;
    }
    const parent = path.dirname(candidate);
    if (parent === candidate) {
        throw new Error("Unsafe project learnings path: no project ancestor");
    }
    return findExistingAncestor(parent);
}
//# sourceMappingURL=learnings-file-safety.js.map