layers_define.js

const { compile } = require('struct-compile');
const { checksums } = require('#lib/bindings');
const mixins = require('./mixins');

/**
 * Creates a Layer class on top of a struct-compile-generated header.
 *
 * The generated constructor follows the immutable-packet pattern shared
 * by every layer: take a Buffer or an object, allocate the header buffer
 * via toAlloc when given an object, run mixins.ctor for prev/next links,
 * and then set this.length.
 *
 * Returns { Layer, proto, Header, baseLength } so that mixins (attach.*)
 * can wire additional behaviour into the prototype after construction.
 *
 * @param {string} name - Layer name (used for class.name and instance.name).
 * @param {string} structSrc - struct-compile source string.
 * @param {Object} [meta]
 * @param {number} [meta.osi] - OsiModelLayers value.
 * @param {number} [meta.linktype] - LinkLayerType value (for L2 layers).
 * @param {Function|false} [meta.length] - `(self) => number` for runtime
 *   length (e.g. headerLength*4). `false` to skip the assignment entirely
 *   when the layer installs its own length getter on the prototype
 *   (ICMP/ICMPv6, where length depends on `type`). Defaults to baseLength.
 * @param {Function} [meta.toAlloc] - `(data) => number` for allocation
 *   size when constructing from an object. Defaults to constant baseLength.
 *   `attach.options.tlv8` wraps this to add the serialized options length.
 */
function makeLayer(name, structSrc, { osi, linktype, length, toAlloc } = {}) {
  const compiled = compile(structSrc);
  const headerKey = Object.keys(compiled).find(k => k.endsWith('Header')) ?? Object.keys(compiled)[0];
  const Header = compiled[headerKey];
  const baseLength = Header.prototype.config.length;

  // Pick a constructor variant once, at module load. Per-call branching on
  // `typeof length === 'function'` made the makeLayer ctor megamorphic
  // across layers and cost ~12% on parse hot paths in benchmarks; binding
  // the right path into a separate ctor body keeps each Layer's call site
  // monomorphic.
  let Layer;
  if (length === false) {
    Layer = class extends Header {
      constructor(data = {}, opts = {}) {
        if (Buffer.isBuffer(data)) super(data);
        else { super(Buffer.alloc(Layer.toAlloc(data))); this.merge(data); }
        mixins.ctor(this, data, opts);
      }
      defaults() {}
      nextProto() { return null; }
    };
  }
  else if (typeof length === 'function') {
    const lengthFn = length;
    Layer = class extends Header {
      constructor(data = {}, opts = {}) {
        if (Buffer.isBuffer(data)) super(data);
        else { super(Buffer.alloc(Layer.toAlloc(data))); this.merge(data); }
        mixins.ctor(this, data, opts);
        this.length = opts.allocated ?? lengthFn(this);
      }
      defaults() {}
      nextProto() { return null; }
    };
  }
  else {
    Layer = class extends Header {
      constructor(data = {}, opts = {}) {
        if (Buffer.isBuffer(data)) super(data);
        else { super(Buffer.alloc(Layer.toAlloc(data))); this.merge(data); }
        mixins.ctor(this, data, opts);
        this.length = opts.allocated ?? baseLength;
      }
      defaults() {}
      nextProto() { return null; }
    };
  }

  Object.defineProperty(Layer, 'name', { value: name });
  Layer.prototype.name = name;
  Layer.osi = osi;
  Layer.prototype.osi = osi;
  if (linktype !== undefined) Layer.linktype = linktype;
  Layer.toAlloc = toAlloc ?? (() => baseLength);

  return { Layer, proto: Layer.prototype, Header, baseLength };
}

// Property names appearing on the wire are validated to be plain JS
// identifiers so we can splice them into generated code without
// quoting/bracket notation. Every protocol layer in this project follows
// that constraint, but assert it explicitly so a future field name with
// a dash or digit-prefix doesn't silently produce a syntax error.
const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
function assertIdent(s) {
  if (!IDENT_RE.test(s)) {
    throw new Error(`define.js: ${JSON.stringify(s)} is not a valid JS identifier`);
  }
  return s;
}

const attach = {
  /**
   * Adds a getter/setter pair on the prototype for a virtual field that's
   * computed (rather than stored verbatim) - e.g. macToString, inetNtop.
   */
  virtualField(proto, name, { get, set }) {
    Object.defineProperty(proto, name, {
      get,
      set,
      enumerable: true,
      configurable: true,
    });
  },

  /**
   * Wraps proto.toObject (or its parent's via the prototype chain) to
   * overwrite the listed fields with the values returned by their virtual
   * getters. struct-compile's default toObject expands fixed-size byte
   * arrays via multiDimGet, so virtual fields like 'src' would otherwise
   * appear as a per-character array of the formatted string.
   */
  toObjectExtras(proto, fieldNames) {
    const previous = proto.toObject;
    proto.toObject = function() {
      const obj = previous.call(this);
      for (const name of fieldNames) {
        obj[name] = this[name];
      }
      return obj;
    };
  },

  /**
   * Generates proto.defaults(obj, layers) via codegen so that property
   * access compiles to literal-named loads/stores (`this.version`)
   * rather than keyed access (`this[key]`). Closure-iterating a table
   * with `for (const key in table)` measurably regresses build-path perf
   * because V8's keyed-IC cannot inline as tightly as named-property IC.
   *
   * Each entry in `table`:
   *   - primitive value -> `if (obj.x === undefined) this.x = <literal>;`
   *   - function ($, layers) -> `if (obj.x === undefined) this.x = fns[i](this, layers);`
   */
  defaults(proto, table) {
    const fns = [];
    const lines = [];
    for (const key of Object.keys(table)) {
      assertIdent(key);
      const v = table[key];
      if (typeof v === 'function') {
        const idx = fns.length;
        fns.push(v);
        lines.push(`if (obj.${key} === undefined) this.${key} = __fns[${idx}](this, layers);`);
      }
      else {
        lines.push(`if (obj.${key} === undefined) this.${key} = ${JSON.stringify(v)};`);
      }
    }
    proto.defaults = new Function(
      '__fns',
      `return function defaults(obj, layers) {\n  if (obj == null) obj = {};\n  ${lines.join('\n  ')}\n};`,
    )(fns);
  },

  /**
   * Installs the dispatcher's apply() to wire proto.nextProto.
   */
  dispatch(proto, dispatcher) {
    dispatcher.apply(proto);
  },

  /**
   * TLV options helpers and L4 checksum helpers are namespaced for
   * readability at call sites: `attach.options.tlv8(...)`,
   * `attach.checksum.ip(proto)`, `attach.checksum.pseudo(proto, type)`.
   */
  options: {
    /**
     * Wires variable-length 8-bit-type-and-length TLV options. Wraps
     * mixins.withOptions (which provides the `options` getter/setter
     * and `optionsLength` helper) and additionally:
     *   - bumps Layer.toAlloc to include the serialized options length;
     *   - chains proto.merge so that data.options is written via the
     *     options setter after the struct fields are merged;
     *   - chains proto.toObject to expose options as a plain array.
     *
     * @param {Function} Layer - The layer class returned by makeLayer.
     * @param {Object} proto - Layer.prototype (returned by makeLayer).
     * @param {Object} cfg - { baseLength, skipTypes?, lengthIsTotal? }.
     */
    tlv8(Layer, proto, { baseLength, skipTypes, lengthIsTotal }) {
      mixins.withOptions(proto, { baseLength, skipTypes, lengthIsTotal });

      const previousToAlloc = Layer.toAlloc;
      Layer.toAlloc = (data) =>
        previousToAlloc(data) + proto.optionsLength(data && data.options);

      const previousMerge = proto.merge;
      proto.merge = function(data) {
        previousMerge.call(this, data);
        if (data && !Buffer.isBuffer(data) && data.options) {
          this.options = data.options;
        }
      };

      const previousToObject = proto.toObject;
      proto.toObject = function() {
        return { ...previousToObject.call(this), options: [...this.options] };
      };
    },
  },

  /**
   * IP-style checksum (IPv4 header, ICMP) and pseudo-header L4 checksum
   * (TCP/UDP) installers. Both define proto.calculateChecksum and
   * proto.checksums(obj) so Packet._build's checksum pass picks them up.
   */
  checksum: {
    ip(proto) {
      proto.calculateChecksum = function() {
        this.checksum = checksums.ip(this.buffer.subarray(0, this.length));
      };
      proto.checksums = function(obj) {
        if (!obj.checksum) {
          this.calculateChecksum();
        }
      };
    },
    pseudo(proto, protocolType) {
      proto.calculateChecksum = function() {
        this.checksum = checksums.pseudo({
          data: this.buffer,
          addrType: this.prev?.name ?? 'IPv4',
          src: this.prev?.src,
          dst: this.prev?.dst,
          protocolType,
        });
      };
      proto.checksums = function(obj) {
        if (!obj.checksum) {
          this.calculateChecksum();
        }
      };
    },
  },
};

/**
 * Forward dispatcher by a header field.
 *
 * @param {string} fieldName - Field on the layer instance whose value
 *   selects the next layer class via `table`.
 * @param {Object.<number, string>} table - Map field-value -> layer-name.
 *
 * Returns:
 *   - apply(proto): wires proto.nextProto to look up the next layer class
 *     in the runtime `layers` map by table[this[fieldName]]. Falls back
 *     to layers.Payload when the value is unknown. The function body is
 *     generated via new Function so that `this.${fieldName}` is a named
 *     property load (LdaNamedProperty) rather than a keyed one.
 *   - reverseDefault(fallback): builds a defaults-table function that, on
 *     build, picks the field value back from this.next.name. Removes the
 *     duplication between forward (parse) and reverse (build) maps that
 *     was previously split between child.lookupChild / child.lookupKey.
 */
function byField(fieldName, table) {
  assertIdent(fieldName);
  const reverse = new Map(
    Object.entries(table).map(([k, v]) => [v, Number.isNaN(Number(k)) ? k : Number(k)])
  );

  return {
    apply(proto) {
      proto.nextProto = new Function(
        '__table',
        `return function nextProto(layers) {
  const Cls = layers[__table[this.${fieldName}]] ?? layers.Payload;
  return new Cls(this.buffer.subarray(this.length), { ...this.opts, prev: this });
};`,
      )(table);
    },
    reverseDefault(fallback) {
      return ($, _layers) => $.next ? (reverse.get($.next.name) ?? fallback) : fallback;
    },
  };
}

/**
 * Port-based dispatcher (UDP). Looks up the child layer class by
 * destination port first, then source port, and bounds the slice by
 * `this.totalLength` so trailing Ethernet padding doesn't bleed into
 * the child layer.
 *
 * Codegen hardcodes the `this.dst` / `this.src` / `this.totalLength`
 * named property loads for V8 inline-cache friendliness.
 *
 * No reverseDefault: there's no canonical inverse mapping (which port
 * a UDP datagram should advertise depends on direction).
 */
function byPort(table) {
  return {
    apply(proto) {
      proto.nextProto = new Function(
        '__table',
        `return function nextProto(layers) {
  const Cls = layers[__table[this.dst]] ?? layers[__table[this.src]] ?? layers.Payload;
  const end = Math.min(this._buf.length, this.totalLength);
  return new Cls(this._buf.subarray(this.length, end), { ...this.opts, prev: this });
};`,
      )(table);
    },
  };
}

module.exports = { makeLayer, attach, byField, byPort };