<?php

/**
 * Improve PHP error logs and optionally route errors, exceptions and debugging
 * information to a central listener.
 *
 * This class takes over the PHP error and exception handlers when you call
 * ##PhutilErrorHandler::initialize()## and forwards all debugging information
 * to a listener you install with ##PhutilErrorHandler::setErrorListener()##.
 *
 * To use PhutilErrorHandler, which will enhance the messages printed to the
 * PHP error log, just initialize it:
 *
 *    PhutilErrorHandler::initialize();
 *
 * To additionally install a custom listener which can print error information
 * to some other file or console, register a listener:
 *
 *    PhutilErrorHandler::setErrorListener($some_callback);
 *
 * For information on writing an error listener, see
 * @{function:phutil_error_listener_example}. Providing a listener is optional,
 * you will benefit from improved error logs even without one.
 *
 * Phabricator uses this class to drive the DarkConsole "Error Log" plugin.
 *
 * @task config   Configuring Error Dispatch
 * @task exutil   Exception Utilities
 * @task trap     Error Traps
 * @task internal Internals
 */
final class PhutilErrorHandler extends Phobject {

  private static $errorListener = null;
  private static $initialized = false;
  private static $traps = array();

  const EXCEPTION   = 'exception';
  const ERROR       = 'error';
  const PHLOG       = 'phlog';
  const DEPRECATED  = 'deprecated';


/* -(  Configuring Error Dispatch  )----------------------------------------- */


  /**
   * Registers this class as the PHP error and exception handler. This will
   * overwrite any previous handlers!
   *
   * @return void
   * @task config
   */
  public static function initialize() {
    self::$initialized = true;
    set_error_handler(array(__CLASS__, 'handleError'));
    set_exception_handler(array(__CLASS__, 'handleException'));
  }

  /**
   * Provide an optional listener callback which will receive all errors,
   * exceptions and debugging messages. It can then print them to a web console,
   * for example.
   *
   * See @{function:phutil_error_listener_example} for details about the
   * callback parameters and operation.
   *
   * @return void
   * @task config
   */
  public static function setErrorListener($listener) {
    self::$errorListener = $listener;
  }


/* -(  Exception Utilities  )------------------------------------------------ */


  /**
   * Gets the previous exception of a nested exception. Prior to PHP 5.3 you
   * can use @{class:PhutilProxyException} to nest exceptions; after PHP 5.3
   * all exceptions are nestable.
   *
   * @param   Exception|Throwable       Exception to unnest.
   * @return  Exception|Throwable|null  Previous exception, if one exists.
   * @task    exutil
   */
  public static function getPreviousException($ex) {
    if (method_exists($ex, 'getPrevious')) {
      return $ex->getPrevious();
    }
    if (method_exists($ex, 'getPreviousException')) {
      return $ex->getPreviousException();
    }
    return null;
  }


  /**
   * Find the most deeply nested exception from a possibly-nested exception.
   *
   * @param   Exception|Throwable     A possibly-nested exception.
   * @return  Exception|Throwable     Deepest exception in the nest.
   * @task    exutil
   */
  public static function getRootException($ex) {
    $root = $ex;
    while (self::getPreviousException($root)) {
      $root = self::getPreviousException($root);
    }
    return $root;
  }


/* -(  Trapping Errors  )---------------------------------------------------- */


  /**
   * Adds an error trap. Normally you should not invoke this directly;
   * @{class:PhutilErrorTrap} registers itself on construction.
   *
   * @param PhutilErrorTrap Trap to add.
   * @return void
   * @task trap
   */
  public static function addErrorTrap(PhutilErrorTrap $trap) {
    $key = $trap->getTrapKey();
    self::$traps[$key] = $trap;
  }


  /**
   * Removes an error trap. Normally you should not invoke this directly;
   * @{class:PhutilErrorTrap} deregisters itself on destruction.
   *
   * @param PhutilErrorTrap Trap to remove.
   * @return void
   * @task trap
   */
  public static function removeErrorTrap(PhutilErrorTrap $trap) {
    $key = $trap->getTrapKey();
    unset(self::$traps[$key]);
  }


/* -(  Internals  )---------------------------------------------------------- */


  /**
   * Determine if PhutilErrorHandler has been initialized.
   *
   * @return bool True if initialized.
   * @task internal
   */
  public static function hasInitialized() {
    return self::$initialized;
  }


  /**
   * Handles PHP errors and dispatches them forward. This is a callback for
   * ##set_error_handler()##. You should not call this function directly; use
   * @{function:phlog} to print debugging messages or ##trigger_error()## to
   * trigger PHP errors.
   *
   * This handler converts E_RECOVERABLE_ERROR messages from violated typehints
   * into @{class:InvalidArgumentException}s.
   *
   * This handler converts other E_RECOVERABLE_ERRORs into
   * @{class:RuntimeException}s.
   *
   * This handler converts E_NOTICE messages from uses of undefined variables
   * into @{class:RuntimeException}s.
   *
   * @param int Error code.
   * @param string Error message.
   * @param string File where the error occurred.
   * @param int Line on which the error occurred.
   * @param wild Error context information.
   * @return void
   * @task internal
   */
  public static function handleError($num, $str, $file, $line, $ctx) {

    foreach (self::$traps as $trap) {
      $trap->addError($num, $str, $file, $line, $ctx);
    }

    if ((error_reporting() & $num) == 0) {
      // Respect the use of "@" to silence warnings: if this error was
      // emitted from a context where "@" was in effect, the
      // value returned by error_reporting() will be 0. This is the
      // recommended way to check for this, see set_error_handler() docs
      // on php.net.
      return false;
    }

    // See T13499. If this is a user error arising from "trigger_error()" or
    // similar, route it through normal error handling: this is probably the
    // best match to authorial intent, since the code could choose to throw
    // an exception instead if it wanted that behavior. Phabricator does not
    // use "trigger_error()" so we never normally expect to reach this
    // block in first-party code.

    if (($num === E_USER_ERROR) ||
        ($num === E_USER_WARNING) ||
        ($num === E_USER_NOTICE)) {

      $trace = debug_backtrace();
      array_shift($trace);
      self::dispatchErrorMessage(
        self::ERROR,
        $str,
        array(
          'file'       => $file,
          'line'       => $line,
          'context'    => $ctx,
          'error_code' => $num,
          'trace'      => $trace,
        ));

      return;
    }

    // Convert typehint failures into exceptions.
    if (preg_match('/^Argument (\d+) passed to (\S+) must be/', $str)) {
      throw new InvalidArgumentException($str);
    }

    // Convert other E_RECOVERABLE_ERRORs into generic runtime exceptions.
    if ($num == E_RECOVERABLE_ERROR) {
      throw new RuntimeException($str);
    }

    // Convert uses of undefined variables into exceptions.
    if (preg_match('/^Undefined variable: /', $str)) {
      throw new RuntimeException($str);
    }

    // Convert uses of undefined properties into exceptions.
    if (preg_match('/^Undefined property: /', $str)) {
      throw new RuntimeException($str);
    }

    // Convert undefined constants into exceptions. Usually this means there
    // is a missing `$` and the program is horribly broken.
    if (preg_match('/^Use of undefined constant /', $str)) {
      throw new RuntimeException($str);
    }

    // Convert undefined indexes into exceptions.
    if (preg_match('/^Undefined index: /', $str)) {
      throw new RuntimeException($str);
    }

    // Convert undefined offsets into exceptions.
    if (preg_match('/^Undefined offset: /', $str)) {
      throw new RuntimeException($str);
    }

    // See T13499. Convert all other runtime errors not handled in a more
    // specific way into runtime exceptions.
    throw new RuntimeException($str);
  }

  /**
   * Handles PHP exceptions and dispatches them forward. This is a callback for
   * ##set_exception_handler()##. You should not call this function directly;
   * to print exceptions, pass the exception object to @{function:phlog}.
   *
   * @param Exception|Throwable Uncaught exception object.
   * @return void
   * @task internal
   */
  public static function handleException($ex) {
    self::dispatchErrorMessage(
      self::EXCEPTION,
      $ex,
      array(
        'file'  => $ex->getFile(),
        'line'  => $ex->getLine(),
        'trace' => self::getExceptionTrace($ex),
        'catch_trace' => debug_backtrace(),
      ));

    // Normally, PHP exits with code 255 after an uncaught exception is thrown.
    // However, if we install an exception handler (as we have here), it exits
    // with code 0 instead. Script execution terminates after this function
    // exits in either case, so exit explicitly with the correct exit code.
    exit(255);
  }


  /**
   * Output a stacktrace to the PHP error log.
   *
   * @param trace A stacktrace, e.g. from debug_backtrace();
   * @return void
   * @task internal
   */
  public static function outputStacktrace($trace) {
    $lines = explode("\n", self::formatStacktrace($trace));
    foreach ($lines as $line) {
      error_log($line);
    }
  }


  /**
   * Format a stacktrace for output.
   *
   * @param trace A stacktrace, e.g. from debug_backtrace();
   * @return string Human-readable trace.
   * @task internal
   */
  public static function formatStacktrace($trace) {
    $result = array();

    $libinfo = self::getLibraryVersions();
    if ($libinfo) {
      foreach ($libinfo as $key => $dict) {
        $info = array();
        foreach ($dict as $dkey => $dval) {
          $info[] = $dkey.'='.$dval;
        }
        $libinfo[$key] = $key.'('.implode(', ', $info).')';
      }
      $result[] = implode(', ', $libinfo);
    }

    foreach ($trace as $key => $entry) {
      $line = '  #'.$key.' ';
      if (!empty($entry['xid'])) {
        if ($entry['xid'] != 1) {
          $line .= '<#'.$entry['xid'].'> ';
        }
      }
      if (isset($entry['class'])) {
        $line .= $entry['class'].'::';
      }
      $line .= idx($entry, 'function', '');

      if (isset($entry['args'])) {
        $args = array();
        foreach ($entry['args'] as $arg) {

          // NOTE: Print out object types, not values. Values sometimes contain
          // sensitive information and are usually not particularly helpful
          // for debugging.

          $type = (gettype($arg) == 'object')
            ? get_class($arg)
            : gettype($arg);
          $args[] = $type;
        }
        $line .= '('.implode(', ', $args).')';
      }

      if (isset($entry['file'])) {
        $file = self::adjustFilePath($entry['file']);
        $line .= ' called at ['.$file.':'.$entry['line'].']';
      }

      $result[] = $line;
    }
    return implode("\n", $result);
  }


  /**
   * All different types of error messages come here before they are
   * dispatched to the listener; this method also prints them to the PHP error
   * log.
   *
   * @param const Event type constant.
   * @param wild Event value.
   * @param dict Event metadata.
   * @return void
   * @task internal
   */
  public static function dispatchErrorMessage($event, $value, $metadata) {
    $timestamp = strftime('%Y-%m-%d %H:%M:%S');

    switch ($event) {
      case self::ERROR:
        $default_message = sprintf(
          '[%s] ERROR %d: %s at [%s:%d]',
          $timestamp,
          $metadata['error_code'],
          $value,
          $metadata['file'],
          $metadata['line']);

        $metadata['default_message'] = $default_message;
        error_log($default_message);
        self::outputStacktrace($metadata['trace']);
        break;
      case self::EXCEPTION:
        $messages = array();
        $current = $value;
        do {
          $messages[] = '('.get_class($current).') '.$current->getMessage();
        } while ($current = self::getPreviousException($current));
        $messages = implode(' {>} ', $messages);

        if (strlen($messages) > 4096) {
          $messages = substr($messages, 0, 4096).'...';
        }

        $default_message = sprintf(
          '[%s] EXCEPTION: %s at [%s:%d]',
          $timestamp,
          $messages,
          self::adjustFilePath(self::getRootException($value)->getFile()),
          self::getRootException($value)->getLine());

        $metadata['default_message'] = $default_message;
        error_log($default_message);
        self::outputStacktrace($metadata['trace']);
        break;
      case self::PHLOG:
        $default_message = sprintf(
          '[%s] PHLOG: %s at [%s:%d]',
          $timestamp,
          PhutilReadableSerializer::printShort($value),
          $metadata['file'],
          $metadata['line']);

        $metadata['default_message'] = $default_message;
        error_log($default_message);
        break;
      case self::DEPRECATED:
        $default_message = sprintf(
          '[%s] DEPRECATED: %s is deprecated; %s',
          $timestamp,
          $value,
          $metadata['why']);

        $metadata['default_message'] = $default_message;
        error_log($default_message);
        break;
      default:
        error_log(pht('Unknown event %s', $event));
        break;
    }

    if (self::$errorListener) {
      static $handling_error;
      if ($handling_error) {
        error_log(
          'Error handler was reentered, some errors were not passed to the '.
          'listener.');
        return;
      }
      $handling_error = true;
      call_user_func(self::$errorListener, $event, $value, $metadata);
      $handling_error = false;
    }
  }

  public static function adjustFilePath($path) {
    // Compute known library locations so we can emit relative paths if the
    // file resides inside a known library. This is a little cleaner to read,
    // and limits the number of false positives we get about full path
    // disclosure via HackerOne.

    $bootloader = PhutilBootloader::getInstance();
    $libraries = $bootloader->getAllLibraries();
    $roots = array();
    foreach ($libraries as $library) {
      $root = $bootloader->getLibraryRoot($library);
      // For these libraries, the effective root is one level up.
      switch ($library) {
        case 'arcanist':
        case 'phabricator':
          $root = dirname($root);
          break;
      }

      if (!strncmp($root, $path, strlen($root))) {
        return '<'.$library.'>'.substr($path, strlen($root));
      }
    }

    return $path;
  }

  public static function getLibraryVersions() {
    $libinfo = array();

    $bootloader = PhutilBootloader::getInstance();
    foreach ($bootloader->getAllLibraries() as $library) {
      $root = phutil_get_library_root($library);
      $try_paths = array(
        $root,
        dirname($root),
      );
      $libinfo[$library] = array();

      $get_refs = array('master');
      foreach ($try_paths as $try_path) {
        // Try to read what the HEAD of the repository is pointed at. This is
        // normally the name of a branch ("ref").
        $try_file = $try_path.'/.git/HEAD';
        if (@file_exists($try_file)) {
          $head = @file_get_contents($try_file);
          $matches = null;
          if (preg_match('(^ref: refs/heads/(.*)$)', trim($head), $matches)) {
            $libinfo[$library]['head'] = trim($matches[1]);
            $get_refs[] = trim($matches[1]);
          } else {
            $libinfo[$library]['head'] = trim($head);
          }
          break;
        }
      }

      // Try to read which commit relevant branch heads are at.
      foreach (array_unique($get_refs) as $ref) {
        foreach ($try_paths as $try_path) {
          $try_file = $try_path.'/.git/refs/heads/'.$ref;
          if (@file_exists($try_file)) {
            $hash = @file_get_contents($try_file);
            if ($hash) {
              $libinfo[$library]['ref.'.$ref] = substr(trim($hash), 0, 12);
              break;
            }
          }
        }
      }

      // Look for extension files.
      $custom = @scandir($root.'/extensions/');
      if ($custom) {
        $count = 0;
        foreach ($custom as $custom_path) {
          if (preg_match('/\.php$/', $custom_path)) {
            $count++;
          }
        }
        if ($count) {
          $libinfo[$library]['custom'] = $count;
        }
      }
    }

    ksort($libinfo);

    return $libinfo;
  }

  /**
   * Get a full trace across all proxied and aggregated exceptions.
   *
   * This attempts to build a set of stack frames which completely represent
   * all of the places an exception came from, even if it came from multiple
   * origins and has been aggregated or proxied.
   *
   * @param Exception|Throwable Exception to retrieve a trace for.
   * @return list<wild> List of stack frames.
   */
  public static function getExceptionTrace($ex) {
    $id = 1;

    // Keep track of discovered exceptions which we need to build traces for.
    $stack = array(
      array($id, $ex),
    );

    $frames = array();
    while ($info = array_shift($stack)) {
      list($xid, $ex) = $info;

      // We're going from top-level exception down in bredth-first order, but
      // want to build a trace in approximately standard order (deepest part of
      // the call stack to most shallow) so we need to reverse each list of
      // frames and then reverse everything at the end.

      $ex_frames = array_reverse($ex->getTrace());
      $ex_frames = array_values($ex_frames);
      $last_key = (count($ex_frames) - 1);
      foreach ($ex_frames as $frame_key => $frame) {
        $frame['xid'] = $xid;

        // If this is a child/previous exception and we're on the deepest frame
        // and missing file/line data, fill it in from the exception itself.
        if ($xid > 1 && ($frame_key == $last_key)) {
          if (empty($frame['file'])) {
            $frame['file'] = $ex->getFile();
            $frame['line'] = $ex->getLine();
          }
        }

        // Since the exceptions are likely to share the most shallow frames,
        // try to add those to the trace only once.
        if (isset($frame['file']) && isset($frame['line'])) {
          $signature = $frame['file'].':'.$frame['line'];
          if (empty($frames[$signature])) {
            $frames[$signature] = $frame;
          }
        } else {
          $frames[] = $frame;
        }
      }

      // If this is a proxy exception, add the proxied exception.
      $prev = self::getPreviousException($ex);
      if ($prev) {
        $stack[] = array(++$id, $prev);
      }

      // If this is an aggregate exception, add the child exceptions.
      if ($ex instanceof PhutilAggregateException) {
        foreach ($ex->getExceptions() as $child) {
          $stack[] = array(++$id, $child);
        }
      }
    }

    return array_values(array_reverse($frames));
  }

}
