/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const logger = require('../../../core/logger')('contrast:validator:propagator');
const tracker = require('../../../tracker');
const patcher = require('../../../hooks/patcher');
const { PATCH_TYPES } = require('../../../constants');
const moduleHook = require('../../../hooks/require');
const TagRange = require('../../models/tag-range');
const tagRangeUtil = require('../../models/tag-range/util');
const { PropagationEvent, Signature, CallContext } = require('../../models');
const agent = require('../../../agent');

/**
 * this override propagator instruments each of the classes of methods
 * in 'validator-methods.js'. the three classes are:
 *
 * validators: {methodName: 'tag-to-add', ...}
 *   add the specified tag if the validation succeeds
 * untrackers: ['methodName', ...]
 *   unconditionally remove tracking
 * sanitizers: {methodName: 'tag-to-add', ...}
 *   unconditionally add the specified tag
 *
 * for additional context see:
 * https://docs.google.com/spreadsheets/d/17p5C6NOISNuWi8D-07d8gNg8byQytuwjtgBgzGpfU_w/edit#gid=0
 *
 */
module.exports.handle = function () {
  const {
    validators,
    untrackers,
    sanitizers,
  } = require('./validator-methods.js');

  const patchType = PATCH_TYPES.ASSESS_PROPAGATOR;

  /**
   * Patch the object or function.
   * @param {object|function} obj The object being patched
   * @param {string} name This is used to keep track of what's been patched.
   * @param {function} post The post-processing function to invoke
   * @return {function|undefined} If obj was a function, the patched function
   */
  function patch(obj, validator, post) {
    const name = `validator/lib/${validator}`;
    logger.trace(`hooking ${name}`);

    const patched = patcher.patch(obj, {
      name,
      patchType,
      post,
    });
    // babel adds a self-referential default property so if present in the unpatched
    // object update the patched object
    if (obj === obj.default) {
      patched.default = patched;
    }
    // if the object is a function then there is no object in which the original
    // function can be replaced by the patched function. returning the function
    // lets moduleHook.resolve() ensure that any subsequent require of the same
    // file returns the patched function.
    if (typeof obj === 'function') {
      return patched;
    }
    // return undefined
  }

  //+
  // for each validator that returns true, add the tag specified in validator-methods.js
  // and remove the untrusted tag.
  //-
  for (const validator in validators) {
    const signature = new Signature(`validator.${validator}`);
    moduleHook.resolve(
      { name: 'validator', file: `lib/${validator}` },
      (index, meta) => {
        function post(data) {
          if (
            data.result &&
            (validator !== 'matches' ||
              (validator === 'matches' &&
                agent.config.assess.trust_custom_validators))
          ) {
            const trackingData = tracker.getData(data.args[0]);
            if (trackingData) {
              tagRangeUtil.addInPlace(
                trackingData.tagRanges,
                new TagRange(0, data.args[0].length - 1, validators[validator])
              );
              tagRangeUtil.removeInPlace(trackingData.tagRanges, ['untrusted']);

              const context = CallContext.create(data);
              const event = new PropagationEvent({
                context,
                signature,
                tagRanges: trackingData.tagRanges,
                source: 'O',
                target: 'R',
              });

              event.parents.push(trackingData.event);
              trackingData.event = event;
            }
          }
        }

        return patch(index, validator, post);
      }
    );
  }

  //+
  // for each removeTracking add the "trusted" tag which the node agent doesn't
  // support, so simulate it by deleting any existing tags and mark the string
  // untracked.
  //-
  for (let i = 0; i < untrackers.length; i++) {
    const name = untrackers[i];

    moduleHook.resolve(
      { name: 'validator', file: `lib/${name}` },
      (index, meta) => {
        function post(data) {
          if (data.result) {
            const trackingData = tracker.getData(data.args[0]);
            if (trackingData) {
              trackingData.tagRanges = [];
              trackingData.tracked = false;
            }
          }
        }

        return patch(index, name, post);
      }
    );
  }

  //+
  // for each sanitizer, if the input string is already tracked add the indicated
  // tag.
  //-
  for (const sanitizer in sanitizers) {
    moduleHook.resolve(
      { name: 'validator', file: `lib/${sanitizer}` },
      (index, meta) => {
        function post(data) {
          // if not tracked then it can't be untrusted, so don't start
          // tracking this string.
          const inputData = tracker.getData(data.args[0]);
          if (!inputData) return;

          const tag = sanitizers[sanitizer];

          // are the input and output of the sanitizer the same?
          if (data.args[0] !== data.result) {
            let needsTag = true;
            // the input and output strings are not the same. start tracking the output.
            const tracked = tracker.track(data.result);
            // copy the input tags to the result, adjusting the range.
            if (tracked) {
              const stop = data.result.length - 1;
              for (let i = 0; i < inputData.tagRanges.length; i++) {
                tracked.props.tagRanges[i] = new TagRange(
                  0,
                  stop,
                  inputData.tagRanges[i].tag
                );
                if (inputData.tagRanges[i].tag === tag) {
                  needsTag = false;
                }
              }
              if (needsTag) {
                tracked.props.tagRanges.push(
                  new TagRange(0, stop, sanitizers[sanitizer])
                );
              }
              data.result = tracked.str;
            }
          } else {
            // the input and output strings are the same, so the output is already being
            // tracked. if the tag is already present adjust the bounds otherwise add the
            // tag.
            let needsTag = true;
            for (let i = 0; i < inputData.tagRanges.length; i++) {
              if (inputData.tagRanges[i].tag === tag) {
                inputData.tagRanges[i].start = 0;
                inputData.tagRanges[i].stop = data.result.length - 1;
                needsTag = false;
              }
            }
            // if the tag wasn't present add it
            if (needsTag) {
              inputData.tagRanges.push(
                new TagRange(0, data.result.length - 1, tag)
              );
            }
          }
        }

        return patch(index, sanitizer, post);
      }
    );
  }

  moduleHook.resolve({ name: 'validator', file: 'lib/isEmail' }, (isEmail) => {
    const signature = new Signature('validator.isEmail');

    function post(data) {
      const trackingData = tracker.getData(data.args[0]);
      // The default options for the two fields of interest are:
      // `{ allow_display_name: false, require_display_name: false }`
      // so we can use an empty object as it will also yield
      // falsy values for these two fields
      const options = data.args[1] ? data.args[1] : {};
      if (data.result && trackingData && !options.allow_display_name && !options.require_display_name) {
        tagRangeUtil.addInPlace(
          trackingData.tagRanges,
          new TagRange(0, data.args[0].length - 1, 'limited-chars')
        );
        tagRangeUtil.removeInPlace(trackingData.tagRanges, ['untrusted']);

        const context = CallContext.create(data);
        const event = new PropagationEvent({
          context,
          signature,
          tagRanges: trackingData.tagRanges,
          source: 'O',
          target: 'R',
        });

        event.parents.push(trackingData.event);
        trackingData.event = event;
      }
    }

    return patch(isEmail, 'isEmail', post);
  });
};
