// engine/spec-registry/packager.ts — AC-3: Spec Package Format
// Creates and extracts gzipped JSON archives for spec packages.
// Format: gzip({ files: { [relativePath]: contentString } })
import { gzip, gunzip } from 'node:zlib';
import { promisify } from 'node:util';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
/** Maximum package size in bytes (500 KB). */
const MAX_PACKAGE_SIZE = 500 * 1024;
/** Files that may be included in a spec package. */
const ALLOWED_FILES = ['spec.md', 'planu-registry.json'];
/**
 * Pack a spec directory into a gzipped archive buffer.
 * Only includes recognized spec files (spec.md, planu-registry.json).
 * @param specDir - Absolute path to the spec directory.
 * @returns Gzipped buffer containing the archived spec files.
 */
export async function packSpec(specDir) {
    const archive = { files: {} };
    for (const fileName of ALLOWED_FILES) {
        const filePath = path.join(specDir, fileName);
        try {
            const content = await fs.readFile(filePath, 'utf-8');
            archive.files[fileName] = content;
        }
        catch {
            // Optional files may not exist — skip silently
        }
    }
    if (Object.keys(archive.files).length === 0) {
        throw new Error(`No spec files found in ${specDir}. Expected at least one of: ${ALLOWED_FILES.join(', ')}`);
    }
    const json = JSON.stringify(archive);
    const compressed = await gzipAsync(Buffer.from(json, 'utf-8'));
    if (compressed.length > MAX_PACKAGE_SIZE) {
        throw new Error(`Package size ${compressed.length} bytes exceeds limit of ${MAX_PACKAGE_SIZE} bytes (500 KB)`);
    }
    return compressed;
}
/**
 * Unpack a gzipped archive buffer into a target directory.
 * Creates the target directory if it does not exist.
 * @param tarball - Gzipped buffer to extract.
 * @param targetDir - Absolute path to extract files into.
 */
export async function unpackSpec(tarball, targetDir) {
    const decompressed = await gunzipAsync(tarball);
    let archive;
    try {
        archive = JSON.parse(decompressed.toString('utf-8'));
    }
    catch {
        throw new Error('Invalid spec archive: malformed JSON');
    }
    if (!archive.files || typeof archive.files !== 'object') {
        throw new Error('Invalid archive format: missing files object');
    }
    // Validate paths before writing anything
    for (const filePath of Object.keys(archive.files)) {
        assertSafePath(filePath);
    }
    await fs.mkdir(targetDir, { recursive: true });
    for (const [filePath, content] of Object.entries(archive.files)) {
        const fullPath = path.join(targetDir, filePath);
        // Double-check resolved path stays within target
        const resolved = path.resolve(fullPath);
        const resolvedTarget = path.resolve(targetDir);
        /* v8 ignore next 2 -- path traversal guard, tested separately */
        if (!resolved.startsWith(resolvedTarget + path.sep) && resolved !== resolvedTarget) {
            throw new Error(`Path traversal detected: ${filePath}`);
        }
        await fs.writeFile(fullPath, content, 'utf-8');
    }
}
/**
 * Validate a package buffer for structural correctness and size limits.
 * @param tarball - Gzipped buffer to validate.
 * @returns Validation result with errors array.
 */
export async function validatePackage(tarball) {
    const errors = [];
    // Check compressed size
    if (tarball.length > MAX_PACKAGE_SIZE) {
        errors.push(`Package size ${tarball.length} bytes exceeds limit of ${MAX_PACKAGE_SIZE} bytes`);
    }
    // Try to decompress and parse
    let archive;
    try {
        const decompressed = await gunzipAsync(tarball);
        archive = JSON.parse(decompressed.toString('utf-8'));
    }
    catch {
        errors.push('Failed to decompress or parse package. Expected gzipped JSON.');
        return { valid: false, errors };
    }
    /* v8 ignore start -- defensive guard for malformed archives */
    if (!archive.files || typeof archive.files !== 'object') {
        errors.push('Invalid archive format: missing "files" object');
        return { valid: false, errors };
    }
    /* v8 ignore stop */
    // Check for path traversal
    for (const filePath of Object.keys(archive.files)) {
        if (filePath.includes('..') || path.isAbsolute(filePath)) {
            errors.push(`Unsafe path detected: ${filePath}`);
        }
    }
    // Check required files
    if (!archive.files['spec.md']) {
        errors.push('Missing required file: spec.md');
    }
    if (!archive.files['planu-registry.json']) {
        errors.push('Missing required file: planu-registry.json');
    }
    return { valid: errors.length === 0, errors };
}
/** Assert that a file path is safe (no traversal, not absolute). */
function assertSafePath(filePath) {
    if (filePath.includes('..')) {
        throw new Error(`Path traversal detected in archive: ${filePath}`);
    }
    if (path.isAbsolute(filePath)) {
        throw new Error(`Absolute path detected in archive: ${filePath}`);
    }
}
//# sourceMappingURL=packager.js.map