// lib/exporters/path-validator.mjs
// Validate textfile output paths for the OTel exporter.
//
// CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored
// directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`,
// `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via
// `fs.realpathSync` before checking.

import { realpathSync, existsSync, statSync } from 'node:fs';
import { resolve, normalize, sep } from 'node:path';
import { ok, fail, issue } from '../util/result.mjs';

// macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp,
// /private/var; realpathSync resolves the symlink and adds the /private prefix.
// Include both forms so the deny check works on macOS + Linux.
const FORBIDDEN_PREFIXES = [
  '/etc/', '/private/etc/',
  '/proc/',
  '/sys/',
  '/var/', '/private/var/',
  '/usr/',
  '/bin/',
  '/sbin/',
  '/boot/',
  '/dev/',
];

/**
 * Validate that a path is safe for the OTel textfile exporter to write.
 *
 * @param {string} path        Caller-supplied path.
 * @param {{
 *   allowedRoots?: string[]  // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR)
 * }} [opts]
 * @returns {import('../util/result.mjs').Result}
 */
export function validateTextfilePath(path, opts = {}) {
  if (typeof path !== 'string' || path.length === 0) {
    return fail(issue('PATH_EMPTY', 'Path must be a non-empty string'));
  }

  // Reject home-shorthand — caller must expand explicitly
  if (path.startsWith('~')) {
    return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`));
  }

  // Normalize to absolute (relative becomes resolved against cwd)
  const normalized = normalize(path);

  // Reject any path component containing `..` (traversal attempt)
  // Even after normalize, if `..` survives, the path leaves intended root.
  const segments = normalized.split(sep);
  if (segments.some(s => s === '..')) {
    return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`));
  }

  const absolute = resolve(normalized);

  // Resolve symlinks if file exists; if it doesn't exist yet, resolve parent
  let resolved;
  try {
    if (existsSync(absolute)) {
      resolved = realpathSync(absolute);
    } else {
      // Resolve parent dir (which must exist for any meaningful write target)
      const parent = absolute.split(sep).slice(0, -1).join(sep) || '/';
      if (!existsSync(parent)) {
        return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`));
      }
      resolved = realpathSync(parent) + sep + absolute.split(sep).pop();
    }
  } catch (e) {
    return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`));
  }

  // If allowedRoots is provided, that's the primary defense — caller has
  // explicitly opted into a root. Reject anything outside; accept anything
  // inside (callers vetting their roots is the threat model).
  if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) {
    const inside = opts.allowedRoots.some(root => {
      if (typeof root !== 'string' || root.length === 0) return false;
      let resolvedRoot;
      try { resolvedRoot = realpathSync(root); }
      catch { resolvedRoot = resolve(root); }
      return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
    });
    if (!inside) {
      return fail(issue('PATH_OUT_OF_ALLOWLIST',
        `Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`));
    }
    return ok({ path: resolved });
  }

  // No allowedRoots: fall back to forbidden-system-prefix denylist.
  for (const prefix of FORBIDDEN_PREFIXES) {
    if (resolved.startsWith(prefix)) {
      return fail(issue('PATH_FORBIDDEN_SYSTEM',
        `Path resolves into forbidden system directory ${prefix}: ${resolved}`));
    }
  }

  return ok({ path: resolved });
}

export { FORBIDDEN_PREFIXES };
