// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/**
 * Tar archive extraction and integrity verification utilities.
 *
 * These helpers are security-critical: they validate archive entries to
 * prevent path traversal, run a post-extraction scan for symlink escapes,
 * and verify integrity hashes (SRI or hex shasum) before extraction.
 *
 * Extracted from `registry-install.ts` and shared by all syncable
 * providers that fetch tarballs (currently `NpmSourceProvider` and the
 * registry index builder).
 */

import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { isWithin } from "../../core/common";
import type { SourceSpec } from "../../core/config/config";
import { warn } from "../../core/warn";
import { spawnSync } from "../../runtime";

/**
 * Verify an archive's integrity against a known hash. Throws and removes
 * the archive when verification fails.
 *
 * Supports SRI hashes (sha256-/sha512-) and hex SHA-1 from npm.
 * Skips verification for git/github sources (revisions are commit SHAs,
 * not content hashes).
 */
export function verifyArchiveIntegrity(
  archivePath: string,
  expected: string | undefined,
  source?: SourceSpec["type"],
): void {
  if (!expected) return;

  // For GitHub and git sources, resolvedRevision is a commit SHA, not a content hash.
  // Content integrity cannot be verified from a commit hash, so skip verification.
  if (source === "github" || source === "git") return;

  const fileBuffer = fs.readFileSync(archivePath);

  // SRI hash format: sha256-<base64> or sha512-<base64>
  if (expected.startsWith("sha256-") || expected.startsWith("sha512-")) {
    const dashIndex = expected.indexOf("-");
    const algorithm = expected.slice(0, dashIndex);
    const expectedBase64 = expected.slice(dashIndex + 1);
    const actualBase64 = createHash(algorithm).update(fileBuffer).digest("base64");
    if (actualBase64 !== expectedBase64) {
      fs.unlinkSync(archivePath);
      throw new Error(
        `Integrity check failed for ${archivePath}: expected ${algorithm} digest ${expectedBase64}, got ${actualBase64}`,
      );
    }
    return;
  }

  // Hex shasum (SHA-1 from npm)
  if (/^[0-9a-f]{40}$/i.test(expected)) {
    const actualHex = createHash("sha1").update(fileBuffer).digest("hex");
    if (actualHex.toLowerCase() !== expected.toLowerCase()) {
      fs.unlinkSync(archivePath);
      throw new Error(`Integrity check failed for ${archivePath}: expected sha1 ${expected}, got ${actualHex}`);
    }
    return;
  }

  // Unrecognized format — warn and skip verification
  warn("Unrecognized integrity format: %s — verification skipped", expected);
}

/**
 * Extract a tar.gz archive into `destinationDir`, validating entries first
 * (no absolute paths, no `..` traversal, no NUL bytes), invoking tar with
 * `--no-same-owner --strip-components=1`, and finally scanning the extracted
 * tree for symlinks that would escape the destination.
 */
export function extractTarGzSecure(archivePath: string, destinationDir: string): void {
  const listResult = spawnSync(["tar", "tzf", archivePath]);
  if (!listResult.success) {
    const err = listResult.stderr?.toString().trim() || "unknown error";
    throw new Error(`Failed to inspect archive ${archivePath}: ${err}`);
  }

  validateTarEntries(listResult.stdout.toString());

  fs.rmSync(destinationDir, { recursive: true, force: true });
  fs.mkdirSync(destinationDir, { recursive: true });

  const extractResult = spawnSync([
    "tar",
    "xzf",
    archivePath,
    "--no-same-owner",
    "--strip-components=1",
    "-C",
    destinationDir,
  ]);
  if (!extractResult.success) {
    const err = extractResult.stderr?.toString().trim() || "unknown error";
    throw new Error(`Failed to extract archive ${archivePath}: ${err}`);
  }

  // Post-extraction scan: verify all extracted files are within destinationDir
  // This mitigates TOCTOU between validateTarEntries (list) and tar extract.
  scanExtractedFiles(destinationDir, destinationDir);
}

function scanExtractedFiles(dir: string, root: string): void {
  let entries: fs.Dirent[];
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return;
  }
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    // Reject only entries whose name is exactly the parent-traversal segment
    // (or `.`). Substring matches (`foo..bar`, `archive..2024.tar`) are
    // legitimate filenames that the previous `entry.name.includes("..")`
    // check rejected as false positives — flagged in PR #168 review.
    if (entry.name === ".." || entry.name === ".") {
      throw new Error(`Post-extraction scan: suspicious entry name: ${fullPath}`);
    }
    // Symlinks: resolve and confirm the target stays inside the destination.
    if (entry.isSymbolicLink()) {
      const target = fs.realpathSync(fullPath);
      if (!isWithin(target, root)) {
        throw new Error(`Post-extraction scan: symlink escapes destination directory: ${fullPath} -> ${target}`);
      }
    }
    // Belt-and-suspenders: check that the resolved path of regular entries
    // stays within the destination root. This catches path traversal attempts
    // via symlink TOCTOU, directory renames, or any other anomalies.
    if (!entry.isSymbolicLink()) {
      const resolved = path.resolve(fullPath);
      if (!isWithin(resolved, root)) {
        throw new Error(`Post-extraction scan: entry escapes destination directory: ${fullPath}`);
      }
    }
    if (entry.isDirectory()) {
      scanExtractedFiles(fullPath, root);
    }
  }
}

/**
 * Validate the line-oriented `tar tzf` listing for unsafe entries.
 *
 * Rejects:
 *   - empty/NUL-containing entries
 *   - absolute paths
 *   - parent traversal (`..` / `../`)
 *   - any entry that would still escape after `--strip-components=1`
 */
export function validateTarEntries(listOutput: string): void {
  const lines = listOutput.split(/\r?\n/).filter(Boolean);
  for (const rawLine of lines) {
    const entry = rawLine.trim();
    if (!entry || entry.includes("\0")) {
      throw new Error(`Archive contains an invalid entry: ${JSON.stringify(rawLine)}`);
    }
    if (entry.startsWith("/")) {
      throw new Error(`Archive contains an absolute path entry: ${entry}`);
    }

    const normalized = path.posix.normalize(entry);
    if (normalized === ".." || normalized.startsWith("../")) {
      throw new Error(`Archive contains a path traversal entry: ${entry}`);
    }

    const parts = normalized.split("/").filter(Boolean);
    const stripped = parts.slice(1).join("/");
    if (!stripped) continue;
    const normalizedStripped = path.posix.normalize(stripped);
    if (
      normalizedStripped === ".." ||
      normalizedStripped.startsWith("../") ||
      path.posix.isAbsolute(normalizedStripped)
    ) {
      throw new Error(`Archive contains an unsafe entry after strip-components: ${entry}`);
    }
  }
}
