/**
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.
*/
/**
 * This module exports a single static function as an interface for generic
 * dataflow propagation. The rules for the propagation handled within are
 * defined in the policy via a descriptor. Custom propagation rules, requiring
 * a provider, are not handled here.
 *
 * @module lib/assess/propagators/manager
 * @see module:lib/assess/propagators/utils
 */
'use strict';

const { PropagationEvent, Signature, CallContext } = require('../models');
const logger = require('../../core/logger')(
  'contrast:rules:propagationManager'
);
const tracker = require('../../tracker.js');
const { isString } = require('../../util/is-string');
const partial = require('../../util/partial');
const { traverse } = require('../../util/traverse');
const { sortParamTypes } = require('./utils');
const TagRange = require('../models/tag-range');
const tagRangeUtil = require('../models/tag-range/util');

/**
 * The caller passes the context/result/args from the hooked function
 * and a descriptor hash from the policy. Based on the rules defined by
 * the descriptor, the appropriate events and tags will follow the propagating
 * data. Possible propagation targets are the function's binding context object,
 * the result of the function call, or one or all of the function's arguments.
 *
 * @static
 * @param   {Object}   obj        The function's binding context
 * @param   {Object}   result     The return value of function call
 * @param   {Object[]} args       The arguments passed to function
 * @param   {Object}   descriptor Rules for carrying out propagation
 * @returns {Object}              Interface: { result, args }
 */
module.exports = function Propagator(agent, propagationDescriptor) {
  return function propagate(data) {
    const {
      tags,
      untags,
      source,
      target,
      signature,
      command: { params, type } = {}
    } = propagationDescriptor;

    /* where a tracked string comes from */
    let sources = extractValidSources(source, data);
    sources = getTrackedSources(
      sources,
      agent.config.agent.node.skip_nested_tracking
    );
    /* where a tracked string goes to */
    const validTarget = extractValidTarget(target, data);
    // no valid targets, no sense in continuing
    if (!validTarget) {
      return;
    }

    // no tracked target and if source is specified it is not tracked
    if (sources.length === 0 && !isTargetTracked(validTarget, tags || untags)) {
      return;
    }

    /**
     * Creates the source propagation event
     */
    const sourceMeta = getSourcesMetadata(sources);
    const { tagRanges: sourceTagRanges, events: sourceEvents } = sourceMeta;
    const sig = new Signature(signature);
    const ctxt = CallContext.create(data);
    const event = new PropagationEvent({
      context: ctxt,
      signature: sig,
      tagRanges: sourceTagRanges,
      source,
      target,
      tags,
      untags
    });

    /**
     * Below here we are dealing with moving tags based on the command.type
     * and adding new source and propagation events
     */
    event.tagRanges = formatTagRanges({
      tags,
      untags,
      sources,
      type,
      sourceTagRanges,
      target: validTarget,
      data,
      params
    });

    // move the tags to the result of propagator
    if (event.tagRanges.length > 0 && validTarget === data.result) {
      const tracked = tracker.track(data.result);
      if (tracked) {
        tracked.props.tagRanges = event.tagRanges;
        tracked.props.event = event;
        data.result = tracked.str;
      }

      event.parents.push(...sourceEvents);
    }
    logger.trace('%s2%s %s --> %s', source, target, sources, [validTarget]);
  };
};

/**
 * Creates the appropriate TagRanges based on the command.type
 * @param {Object} props all of the things!
 * @param {Array} props.tagRanges list of tags for propagator
 * @param {Array} props.untags list of untags for propagator
 * @param {String} [props.type=all] (command.type) of propagator
 * @param {Array} props.sourceTagRanges list of the tag ranges for source
 * @param {String | Object } props.target the result of the propagator
 * @param {Array} props.sources list of all sources(args or obj)
 * @param {Object} props.data the data passed to propagator(args,obj,result)
 * @param {Object} props.params (command.params) specifies the start/stop behavior for tag ranges
 * @return {Array} added tag ranges to props.sourceTagRanges
 *
 */
function formatTagRanges({
  tags,
  untags,
  type = 'all',
  sourceTagRanges,
  target,
  sources,
  data,
  params
}) {
  switch (type) {
    case 'all':
      sourceTagRanges = createAllTagRanges(sourceTagRanges, target);
      break;
    case 'keep':
      // do nothing
      break;
    case 'append':
      sourceTagRanges = createAppendTagRanges(data);
      break;
    case 'subset':
      sourceTagRanges = createSubsetTagRanges({
        sourceTagRanges,
        data,
        params,
        sources,
        target
      });
      break;
    default:
      sourceTagRanges = createAllTagRanges(sourceTagRanges, target);
      break;
  }

  if (tags) {
    tagRangeUtil.addAllInPlace(sourceTagRanges, createNewTags(target, tags));
  }

  tagRangeUtil.removeInPlace(sourceTagRanges, untags);

  return sourceTagRanges;
}

/**
 * Creates a tag range for entire length of target
 * @param {Array} sourceTagRanges list of the tag ranges for source
 * @param {String | Object } target the result of the propagator
 * @returns {Array} list of tag ranges
 */
function createAllTagRanges(sourceTagRanges, target) {
  return sourceTagRanges.map(function(tagRange) {
    return new TagRange(0, target.length - 1, tagRange.tag);
  });
}

/**
 * Creates tag ranges with proper offsets for all tracked sources + args
 * @param {Object} data the data passed to propagator(args,obj,result)
 * @returns {Array} list of tag ranges
 */
function createAppendTagRanges(data) {
  const newTags = [];
  let offset = 0;

  // only try to check if tracked and add tags on source if it is a string
  if (isString(data.obj)) {
    offset = data.obj.length;
    const sourceProps = tracker.getData(data.obj);
    if (sourceProps) {
      tagRangeUtil.addAllInPlace(newTags, sourceProps.tagRanges);
    }
  }

  for (const arg of data.args) {
    if (arg) {
      const props = tracker.getData(arg);
      if (props) {
        tagRangeUtil.addAllWithOffsetInPlace(newTags, props.tagRanges, offset);
      }

      // casting to string to prevent booleans from having NaN
      offset += arg.toString().length;
    }
  }

  return newTags;
}

/**
 * Adjusts value if it is > 0 by either decrementing or incrementing
 *
 * @param {Int} value tag range value
 * @param {String} type enum of decrement or increment
 * @param {Int} adjust value based on type
 */
function adjustValue(value, type) {
  return value <= 0 ? value : type === 'decrement' ? value - 1 : value + 1;
}

/**
 * Creates new tag ranges based on the start/stop values
 * Conditions:
 *  if start and stop, use them
 *  if no start but has a stop, start is 0
 *  if has start but no stop, stop is the length of the first source
 *  no start and stop, start is 0 and stop is the length of the target
 * @param {Array} sourceTagRanges list of the tag ranges for source
 * @param {String | Object } target the result of the propagator
 * @param {Array} sources list of all sources(args or obj)
 * @param {Object} data the data passed to propagator(args,obj,result)
 * @param {Object} params (command.params) specifies the start/stop behavior for tag ranges
 * @return {Array} added tag ranges to sourceTagRanges
 */
function createSubsetTagRanges({
  sourceTagRanges,
  data,
  params,
  sources,
  target
}) {
  let start, stop, length;
  if (params) {
    // the goal here is to extract numbers for data.args based on the rule definition and then use
    // them to derive start/stop for tagRangeUtil.trim
    const paramTypes = sortParamTypes(params, data.args, sources[0].length - 1);

    ({ start, stop, length } = paramTypes);
  }

  if (length) {
    stop = start + (target.length - 1);
    // when stop is 0 it's being explicit
  } else if (start && stop !== undefined) {
    stop = adjustValue(stop, 'decrement');
    // do nothing
  } else if (!start && stop) {
    start = 0;
    stop = adjustValue(stop, 'decrement');
  } else if (start && stop === undefined) {
    stop = sources[0].length - 1;
  } else {
    start = 0;
    stop = target.length - 1;
  }

  if (start > stop) {
    start = adjustValue(start, 'decrement');
    stop = adjustValue(stop, 'increment');
    tagRangeUtil.trimInPlace(sourceTagRanges, stop, start);
  } else {
    tagRangeUtil.trimInPlace(sourceTagRanges, start, stop);
  }

  return sourceTagRanges;
}

module.exports.createSubsetTagRanges = createSubsetTagRanges;

/**
 * Given an object and a list of tag values, will return a collection of TagRange
 * instances which start at 0, and stop at the length of the input object.
 *
 *  @param   {Object}     target         Object used to calculate TagRanges' stop values.
 *  @param   {String[]}   descriptorTags The array of tag values for TagRanges being built.
 *  @returns {TagRange[]}
 */
function createNewTags(target, descriptorTags) {
  return descriptorTags.map(function(tag) {
    return new TagRange(0, target.length - 1, tag);
  });
}

/**
 * Gets tags and events for the collective sources
 * @param {Array} sources list of sources
 * @returns {Object} metadata(tags, events) from tracked strings
 */
function getSourcesMetadata(sources) {
  let tagRanges = [];
  const events = [];
  for (const source of sources) {
    const contrastProperties = tracker.getData(source);
    tagRanges = tagRanges.concat(contrastProperties.tagRanges);
    events.push(contrastProperties.event);
  }

  return {
    tagRanges,
    events
  };
}

/**
 * Checks if a source is tracked
 * @param {String} sourceName key of source(will only be defined if this is an object key)
 * @param {String} source value of the source
 * @return {Boolean} if source is tracked
 */
function isSourceTracked(sourceName, source) {
  if (source) {
    const contrastProperties = tracker.getData(source);
    return !!contrastProperties;
  }

  return false;
}

/**
 * Wrapper around isSourceTracked, only used to push tracked sources to an array
 * @param {Array} trackedSources list of tracked sources
 * @param {String} sourceName key of source(will only be defined if this is an object key)
 * @param {String} source value of the source
 */
function filterTrackedSources(trackedSources, sourceName, source) {
  if (isSourceTracked(sourceName, source)) {
    trackedSources.push(source);
  }
}

/**
 * Get a list of all tracked sources
 * @param {Array} sources list of tracked sources
 * @param {Boolean} skipNested value of config.agent.node.skip_nested_tracking
 * @returns {Array} all tracked sources
 */
function getTrackedSources(sources, skipNested) {
  if (skipNested) {
    return sources.filter(partial(isSourceTracked, 'fakeKey'));
  } else {
    const trackedSources = [];
    traverse(sources, partial(filterTrackedSources, trackedSources));
    return trackedSources;
  }
}
/**
 * If there are tags or untags, it will check to see if the target
 * is tracked
 *
 * @param {String|Object} target the result of the propagator
 * @param {Boolean} hasTags does the propagator have tags or untags
 * @returns {Boolean}
 *
 */
function isTargetTracked(target, hasTags) {
  if (hasTags) {
    const contrastProperties = tracker.getData(target);
    return !!contrastProperties;
  }

  return false;
}
/**
 * checks the target type and decides where to check for valid target
 * @param source {String} R
 * @param data {Object} {args,obj,result} of propagator
 * @return {Array} all valid targets based on type
 */
function extractValidTarget(target, data) {
  let validTarget = null;
  switch (target) {
    case 'R':
      validTarget = data.result;
      break;
    default:
      logger.warn(
        'Invalid target type %s for propagator %s',
        target,
        data.name
      );
      break;
  }

  if (isString(validTarget) && validTarget.length > 0) {
    return validTarget;
  }

  return null;
}
/**
 * checks the source type and decides where to check for valid source
 * @param source {String} P|O|A
 * @param data {Object} {args,obj,result} of propagator
 * @return {Array} all valid sources based on type
 */
function extractValidSources(source, data) {
  let sources = [];
  switch (source) {
    case 'P':
      sources = getValidSources(data.args);
      break;
    case 'O':
      sources = getValidSources([data.obj]);
      break;
    case 'A':
      sources = getValidSources([data.obj].concat(data.args));
      break;
    default:
      logger.warn(
        'Invalid source type %s for propagator %s',
        source,
        data.name
      );
      break;
  }

  return sources;
}

/**
 * Tries to find all the sources that are tracked
 * @param {Array} sources depending on the source it will check on a diff arg to the propagator
 * @return {Array} all tracked sources
 *
 **/
function getValidSources(sources) {
  return sources.filter(function(source) {
    const sourceContrastProperties = tracker.getData(source);
    if (
      isString(source) &&
      (!sourceContrastProperties ||
        (sourceContrastProperties && !sourceContrastProperties.tracked))
    ) {
      return false;
    }
    return true;
    // TODO other types?
  });
}
