import { createReadStream, createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGunzip } from "node:zlib";
import * as tar from "tar";
import yauzl from "yauzl";
import { getPrefixedLogger } from "../../util/logger.js";
const LOG_PREFIX = "[downloadAttachments]";
/**
 * Caps for decompression-bomb protection. These apply per-archive so a single
 * pathological upload cannot exhaust disk or file handles.
 */
export const MAX_ARCHIVE_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MiB per archive
export const MAX_ARCHIVE_ENTRIES = 5_000;
/**
 * Directory names whose contents are skipped during archive extraction. These
 * are dependency / VCS / build directories that LLM tooling almost never needs
 * to inspect and that routinely push archives past MAX_ARCHIVE_ENTRIES — most
 * notably `node_modules`, which can contain tens of thousands of files.
 */
export const EXTRACT_SKIP_DIRECTORY_NAMES = new Set([
    ".git",
    "node_modules",
]);
/**
 * Returns true when any path segment of `entryPath` is in the skip set. The
 * check uses POSIX-style `/` separators because both yauzl and tar emit
 * forward-slash entry names regardless of host platform.
 */
export function shouldSkipArchiveEntry(entryPath) {
    return entryPath
        .split("/")
        .some((segment) => EXTRACT_SKIP_DIRECTORY_NAMES.has(segment));
}
export class ArchiveLimitExceededError extends Error {
    constructor(message) {
        super(message);
        this.name = "ArchiveLimitExceededError";
    }
}
/**
 * Normalizes an untrusted filename for use as a single path segment under a
 * controlled extraction root. The output is guaranteed to never be `"."` or
 * `".."`, both of which would otherwise let a crafted upload (e.g. `"....gz"`
 * collapsed by `getGunzipOutputName`) escape the destination directory.
 */
export function sanitizeFileName(fileName) {
    const sanitized = fileName
        .replace(/[^\w.-]+/g, "_")
        .replace(/^_+/, "")
        .slice(0, 120);
    if (sanitized.length === 0 || sanitized === "." || sanitized === "..") {
        return "attachment";
    }
    return sanitized;
}
/**
 * Throws if `targetPath` does not resolve inside `rootDir`. This is the
 * zip-slip / tar-slip / gzip-slip guard. Callers must invoke this on every
 * path derived from untrusted archive metadata before writing to disk.
 */
export function assertPathWithinRoot(rootDir, targetPath) {
    const normalizedRoot = path.resolve(rootDir);
    const normalizedTarget = path.resolve(targetPath);
    if (normalizedTarget !== normalizedRoot &&
        !normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)) {
        throw new Error(`Path escapes extraction directory: ${targetPath}`);
    }
}
/**
 * Strips the `.gz`/`.gzip` suffix from a filename for use as the gunzip output
 * name. Falls back to `<name>.out` when no recognizable suffix is present.
 */
export function getGunzipOutputName(fileName) {
    const lower = fileName.toLowerCase();
    if (lower.endsWith(".gzip")) {
        return fileName.slice(0, -".gzip".length) || "attachment";
    }
    if (lower.endsWith(".gz")) {
        return fileName.slice(0, -".gz".length) || "attachment";
    }
    return `${fileName}.out`;
}
/**
 * Creates a Transform stream that emits an `ArchiveLimitExceededError` once
 * the accumulated byte count crosses `maxBytes`. Used to bound pipelines whose
 * output would otherwise be unbounded (gzip decompression, signed-URL
 * downloads).
 */
export function createByteCapTransform(maxBytes, errorMessage) {
    let written = 0;
    return new Transform({
        transform(chunk, _enc, cb) {
            written += chunk.length;
            if (written > maxBytes) {
                cb(new ArchiveLimitExceededError(errorMessage));
                return;
            }
            cb(null, chunk);
        },
    });
}
/**
 * Rejects zip entry names that could escape the destination or otherwise
 * confuse the filesystem. `yauzl.validateFileName` flags encoding-level
 * issues; additional checks reject null bytes, absolute paths, and Windows
 * separators that `path.resolve` would not interpret as traversals on Linux.
 */
function rejectUnsafeZipEntryName(fileName) {
    const validationError = yauzl.validateFileName(fileName);
    if (validationError) {
        throw new Error(`Invalid ZIP entry name: ${validationError}`);
    }
    if (fileName.includes("\0")) {
        throw new Error("ZIP entry name contains null byte");
    }
    if (fileName.startsWith("/") || fileName.startsWith("\\")) {
        throw new Error("ZIP entry name is absolute");
    }
    if (fileName.includes("\\")) {
        throw new Error("ZIP entry name contains backslash");
    }
}
export async function extractZipArchive(params) {
    const { archivePath, destinationDir } = params;
    const logger = getPrefixedLogger(LOG_PREFIX);
    return await new Promise((resolve, reject) => {
        let extractedCount = 0;
        let totalExtractedBytes = 0;
        const options = { lazyEntries: true };
        const onOpen = (err, zipFile) => {
            if (err || !zipFile) {
                reject(err ?? new Error("Failed to open ZIP archive"));
                return;
            }
            // yauzl's ZipFile holds an open file descriptor until close() is called.
            // We centralize termination here so every error path (path-traversal
            // rejection, entry-count/size limits, mkdir failure, openReadStream
            // failure, stream errors) releases the fd instead of leaking it until GC.
            let settled = false;
            const settle = (error, value) => {
                if (settled)
                    return;
                settled = true;
                try {
                    zipFile.close();
                }
                catch {
                    // ignore close failures; we're already settling
                }
                if (error) {
                    reject(error);
                }
                else {
                    resolve(value);
                }
            };
            const rejectAndClose = (error) => settle(error, 0);
            zipFile.on("error", rejectAndClose);
            zipFile.readEntry();
            zipFile.on("entry", (entry) => {
                try {
                    rejectUnsafeZipEntryName(entry.fileName);
                }
                catch (error) {
                    logger.warn("Rejected unsafe ZIP entry name", {
                        entryName: entry.fileName,
                        error: error instanceof Error ? error.message : String(error),
                    });
                    rejectAndClose(error instanceof Error ? error : new Error(String(error)));
                    return;
                }
                if (shouldSkipArchiveEntry(entry.fileName)) {
                    zipFile.readEntry();
                    return;
                }
                const outputPath = path.resolve(destinationDir, entry.fileName);
                try {
                    assertPathWithinRoot(destinationDir, outputPath);
                }
                catch (error) {
                    logger.warn("Rejected ZIP entry escaping extraction root", {
                        entryName: entry.fileName,
                        error: error instanceof Error ? error.message : String(error),
                    });
                    rejectAndClose(error instanceof Error ? error : new Error(String(error)));
                    return;
                }
                if (entry.fileName.endsWith("/")) {
                    void mkdir(outputPath, { recursive: true })
                        .then(() => {
                        zipFile.readEntry();
                    })
                        .catch(rejectAndClose);
                    return;
                }
                if (extractedCount >= MAX_ARCHIVE_ENTRIES) {
                    rejectAndClose(new ArchiveLimitExceededError(`ZIP archive exceeds maximum entry count (${MAX_ARCHIVE_ENTRIES})`));
                    return;
                }
                void mkdir(path.dirname(outputPath), { recursive: true })
                    .then(() => {
                    zipFile.openReadStream(entry, (streamError, readStream) => {
                        if (streamError || !readStream) {
                            rejectAndClose(streamError ?? new Error("Failed to read ZIP entry stream"));
                            return;
                        }
                        const writeStream = createWriteStream(outputPath);
                        let entryBytes = 0;
                        let aborted = false;
                        const abort = (abortError) => {
                            if (aborted)
                                return;
                            aborted = true;
                            readStream.unpipe(writeStream);
                            readStream.destroy();
                            writeStream.destroy();
                            rejectAndClose(abortError);
                        };
                        readStream.on("data", (chunk) => {
                            entryBytes += chunk.length;
                            if (totalExtractedBytes + entryBytes >
                                MAX_ARCHIVE_EXTRACTED_BYTES) {
                                abort(new ArchiveLimitExceededError(`ZIP archive exceeds maximum extracted size (${MAX_ARCHIVE_EXTRACTED_BYTES} bytes)`));
                            }
                        });
                        writeStream.on("error", abort);
                        readStream.on("error", abort);
                        writeStream.on("finish", () => {
                            if (aborted)
                                return;
                            totalExtractedBytes += entryBytes;
                            extractedCount += 1;
                            zipFile.readEntry();
                        });
                        readStream.pipe(writeStream);
                    });
                })
                    .catch(rejectAndClose);
            });
            zipFile.on("end", () => {
                settle(null, extractedCount);
            });
        };
        yauzl.open(archivePath, options, onOpen);
    });
}
export async function extractTgzArchive(params) {
    const { archivePath, destinationDir } = params;
    const logger = getPrefixedLogger(LOG_PREFIX);
    let extractedCount = 0;
    let totalExtractedBytes = 0;
    await tar.x({
        cwd: destinationDir,
        file: archivePath,
        preservePaths: false,
        strict: true,
        filter: (entryPath, entry) => {
            if (shouldSkipArchiveEntry(entryPath)) {
                return false;
            }
            const outputPath = path.resolve(destinationDir, entryPath);
            try {
                assertPathWithinRoot(destinationDir, outputPath);
            }
            catch (error) {
                logger.warn("Rejected tar entry escaping extraction root", {
                    entryName: entryPath,
                    error: error instanceof Error ? error.message : String(error),
                });
                return false;
            }
            const entryType = "type" in entry ? entry.type : undefined;
            const isDirectory = entryPath.endsWith("/") || entryType === "Directory";
            if (isDirectory) {
                return true;
            }
            if (extractedCount >= MAX_ARCHIVE_ENTRIES) {
                throw new ArchiveLimitExceededError(`Tar archive exceeds maximum entry count (${MAX_ARCHIVE_ENTRIES})`);
            }
            const entrySize = typeof entry.size === "number" ? entry.size : 0;
            if (totalExtractedBytes + entrySize > MAX_ARCHIVE_EXTRACTED_BYTES) {
                throw new ArchiveLimitExceededError(`Tar archive exceeds maximum extracted size (${MAX_ARCHIVE_EXTRACTED_BYTES} bytes)`);
            }
            totalExtractedBytes += entrySize;
            extractedCount += 1;
            return true;
        },
    });
    return extractedCount;
}
export async function extractGzipArchive(params) {
    const outputName = sanitizeFileName(getGunzipOutputName(params.fileName));
    const outputPath = path.join(params.destinationDir, outputName);
    // sanitizeFileName normalizes `.`/`..` to "attachment", but we still assert
    // the resolved path stays inside the extraction directory as a second layer
    // matching the zip/tgz path-traversal checks.
    assertPathWithinRoot(params.destinationDir, outputPath);
    await mkdir(path.dirname(outputPath), { recursive: true });
    await pipeline(createReadStream(params.archivePath), createGunzip(), createByteCapTransform(MAX_ARCHIVE_EXTRACTED_BYTES, `Gzip archive exceeds maximum extracted size (${MAX_ARCHIVE_EXTRACTED_BYTES} bytes)`), createWriteStream(outputPath));
    return { extractedCount: 1, outputPath };
}
//# sourceMappingURL=archive-extractors.js.map