/**
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 { PROXY_TARGET } = require('../../constants');

class Debraner {
  constructor(object) {
    // assure that a top-level membrane can be removed
    this.root = { root: object };
    // the property-name path to the current object
    this.path = [];

    // restore is a noop if proxies have not been removed.
    this.removed = false;

    // used to avoid walking circular references. it's only used by
    // remove() and debrane() but is exposed here for debugging.
    this.circular = undefined;

    // map of proxies found => PROXY_TARGET return value. not sure this
    // is necessary other than to know whether to replace the object or
    // not.
    this.proxies = new Map();

    // map of objects that were behind a membrane proxy => PROXY_TARGET
    // return value. used to restore membrane proxies after stringifying.
    this.unbranes = new Map();
  }

  remove() {
    // used to avoid walking circular references. each remove() call
    // requires that it is reset.
    this.circular = new Set();

    const debraned = this.debrane(this.root);
    this.removed = true;
    if (this.circular.size) {
      // should this restore and return the original proxied object? stringify
      // will throw on a circular reference and that would leave the object
      // without the original membrane proxies. is there a better way to handle
      // stringify throwing?
    }
    return debraned.root;
  }

  debrane(obj) {
    if (typeof obj !== 'object' || !obj) {
      return obj;
    }

    // check for a proxy before adding this object to the circular set. that
    // prevents the proxy itself from being memorized; just the object behind
    // the proxy.

    const unbraned = obj[PROXY_TARGET];
    if (unbraned) {
      // save the proxied object so it can be restored after stringify. saving
      // the path allows the proxies to be restored without walking the entire
      // object.
      unbraned.proxy = obj;
      unbraned.path = this.path.slice();

      // remember that this unwrapped object maps to this specific
      // membrane and proxy. that allows the tracked property data to
      // be accessed and applied to stringify's result.
      this.unbranes.set(unbraned.target, unbraned);
      this.proxies.set(obj, unbraned);

      let container = this.root;
      const lastIx = unbraned.path.length - 1;
      for (let i = 0; i < lastIx; i++) {
        container = container[unbraned.path[i]];
      }
      // replace the membrane proxy in its container with the non-proxied object
      container[unbraned.path[lastIx]] = unbraned.target;

      // then recurse with the unwrapped object.
      return this.debrane(unbraned.target);
    }

    // has this already been walked?
    if (this.circular.has(obj)) {
      return obj;
    }
    this.circular.add(obj);

    if (obj[Symbol.iterator]) {
      // Array and Buffer are the only two iterable objects that can both
      // contain tainted data and render it as text via 'toString()'. Buffer
      // requires the deliberate intention of the program to both 1) encode
      // a Buffer object as an object and 2) interpret that object as a buffer
      // after JSON.parse. we cannot determine, from the data, that the user's
      // application will take those steps, so only Array is left. If the user
      // application uses Buffer.toString() then taint will be maintained if
      // the buffer was tracked.
      if (Array.isArray(obj)) {
        for (const ix in obj) {
          this.path.push(ix);
          this.debrane(obj[ix]);
          this.path.pop();
        }
        return obj;
      } else {
        // it doesn't matter if this is a String or a string or a typed array.
        // a buffer, as well as typed arrays, could sneak data by that *can*
        // be interpreted as a string. but, given that b = Buffer.from('xzxyy')
        // then JSON.stringify({b}) => {"b": {"type": "Buffer", "data": [...]}}
        // where data is [120, 122, 120, 121, 121] so it requires the program's
        // deliberate intention to interpret the incoming data as a buffer. all
        // typed arrays interpret their contents as numbers. and strings will
        // not be behind a membrane. in theory a Map or a Set can contain user
        // data but neither is rendered by JSON.stringify.
        return obj;
      }
    }

    //
    // don't bother checking all the possible object types, e.g., RegExp, Date,
    // Error, etc. instances. there are a few stray enumerable properties, like
    // regex 'lastIndex', but in general there are so few, and the likelihood of
    // these objects appearing in an object that is to be stringified is so low,
    // that we're likely better off just ignoring them.
    //
    const keys = Object.getOwnPropertyNames(obj);
    for (const key of keys) {
      this.path.push(key);
      this.debrane(obj[key]);
      this.path.pop();
    }
    return obj;
  }

  restore() {
    if (this.removed) {
      this.rebrane(this.root);
      this.removed = false;
    }
    return this.root.root;
  }

  // originally implemented using this.unbranes rather than this.proxies.
  // but this.unbranes doesn't capture more than one proxy on the same
  // object.
  rebrane(obj) {
    for (const [, proxies] of this.proxies) {
      let container = this.root;
      const lastIx = proxies.path.length - 1;
      for (let i = 0; i < lastIx; i++) {
        container = container[proxies.path[i]];
      }
      // replace object in its container with the membrane proxy
      container[proxies.path[lastIx]] = proxies.proxy;
    }
  }
}

module.exports = Debraner;
