'use strict' const { BerReader, BerWriter } = require('@ldapjs/asn1') const Attribute = require('@ldapjs/attribute') /** * Implements an LDAP CHANGE sequence as described in * https://www.rfc-editor.org/rfc/rfc4511.html#section-4.6. */ class Change { #operation #modification /** * @typedef {object} ChangeParameters * @property {string | number} operation One of `add` (0), `delete` (1), or * `replace` (2). Default: `add`. * @property {object | import('@ldapjs/attribute')} modification An attribute * instance or an object that is shaped like an attribute. */ /** * @param {ChangeParameters} input * * @throws When the `modification` parameter is invalid. */ constructor ({ operation = 'add', modification }) { this.operation = operation this.modification = modification } get [Symbol.toStringTag] () { return 'LdapChange' } /** * The attribute that will be modified by the {@link Change}. * * @returns {import('@ldapjs/attribute')} */ get modification () { return this.#modification } /** * Define the attribute to be modified by the {@link Change}. * * @param {object|import('@ldapjs/attribute')} mod * * @throws When `mod` is not an instance of `Attribute` or is not an * `Attribute` shaped object. */ set modification (mod) { if (Attribute.isAttribute(mod) === false) { throw Error('modification must be an Attribute') } if (Object.prototype.toString.call(mod) !== '[object LdapAttribute]') { mod = new Attribute(mod) } this.#modification = mod } /** * Get a plain JavaScript object representation of the change. * * @returns {object} */ get pojo () { return { operation: this.operation, modification: this.modification.pojo } } /** * The string name of the operation that will be performed. * * @returns {string} One of `add`, `delete`, or `replace`. */ get operation () { switch (this.#operation) { case 0x00: { return 'add' } case 0x01: { return 'delete' } case 0x02: { return 'replace' } } } /** * Define the operation that the {@link Change} represents. * * @param {string|number} op May be one of `add` (0), `delete` (1), * or `replace` (2). * * @throws When the `op` is not recognized. */ set operation (op) { if (typeof op === 'string') { op = op.toLowerCase() } switch (op) { case 0x00: case 'add': { this.#operation = 0x00 break } case 0x01: case 'delete': { this.#operation = 0x01 break } case 0x02: case 'replace': { this.#operation = 0x02 break } default: { const type = Number.isInteger(op) ? '0x' + Number(op).toString(16) : op throw Error(`invalid operation type: ${type}`) } } } /** * Serialize the instance to a BER. * * @returns {import('@ldapjs/asn1').BerReader} */ toBer () { const writer = new BerWriter() writer.startSequence() writer.writeEnumeration(this.#operation) const attrBer = this.#modification.toBer() writer.appendBuffer(attrBer.buffer) writer.endSequence() return new BerReader(writer.buffer) } /** * See {@link pojo}. * * @returns {object} */ toJSON () { return this.pojo } /** * Applies a {@link Change} to a `target` object. * * @example * const change = new Change({ * operation: 'add', * modification: { * type: 'cn', * values: ['new'] * } * }) * const target = { * cn: ['old'] * } * Change.apply(change, target) * // target = { cn: ['old', 'new'] } * * @param {Change} change The change to apply. * @param {object} target The object to modify. This object will be mutated * by the function. It should have properties that match the `modification` * of the change. * @param {boolean} scalar When `true`, will convert single-item arrays * to scalar values. Default: `false`. * * @returns {object} The mutated `target`. * * @throws When the `change` is not an instance of {@link Change}. */ static apply (change, target, scalar = false) { if (Change.isChange(change) === false) { throw Error('change must be an instance of Change') } const type = change.modification.type const values = change.modification.values let data = target[type] if (data === undefined) { data = [] } else if (Array.isArray(data) === false) { data = [data] } switch (change.operation) { case 'add': { // Add only new unique entries. const newValues = values.filter(v => data.indexOf(v) === -1) Array.prototype.push.apply(data, newValues) break } case 'delete': { data = data.filter(v => values.indexOf(v) === -1) if (data.length === 0) { // An empty list indicates the attribute should be removed // completely. delete target[type] return target } break } case 'replace': { if (values.length === 0) { // A new value set that is empty is a delete. delete target[type] return target } data = values break } } if (scalar === true && data.length === 1) { // Replace array value with a scalar value if the modified set is // single valued and the operation calls for a scalar. target[type] = data[0] } else { target[type] = data } return target } /** * Determines if an object is an instance of {@link Change}, or at least * resembles the shape of a {@link Change} object. A plain object will match * if it has a `modification` property that matches an `Attribute`, * an `operation` property that is a string or number, and has a `toBer` * method. An object that resembles a {@link Change} does not guarantee * compatibility. A `toString` check is much more accurate. * * @param {Change|object} change * * @returns {boolean} */ static isChange (change) { if (Object.prototype.toString.call(change) === '[object LdapChange]') { return true } if (Object.prototype.toString.call(change) !== '[object Object]') { return false } if ( Attribute.isAttribute(change.modification) === true && (typeof change.operation === 'string' || typeof change.operation === 'number') ) { return true } return false } /** * Compares two {@link Change} instance to determine the priority of the * changes relative to each other. * * @param {Change} change1 * @param {Change} change2 * * @returns {number} -1 for lower priority, 1 for higher priority, and 0 * for equal priority in relation to `change1`, e.g. -1 would mean `change` * has lower priority than `change2`. * * @throws When neither parameter resembles a {@link Change} object. */ static compare (change1, change2) { if (Change.isChange(change1) === false || Change.isChange(change2) === false) { throw Error('can only compare Change instances') } if (change1.operation < change2.operation) { return -1 } if (change1.operation > change2.operation) { return 1 } return Attribute.compare(change1.modification, change2.modification) } /** * Parse a BER into a new {@link Change} object. * * @param {import('@ldapjs/asn1').BerReader} ber The BER to process. It must * be at an offset that starts a new change sequence. The reader will be * advanced to the end of the change sequence by this method. * * @returns {Change} * * @throws When there is an error processing the BER. */ static fromBer (ber) { ber.readSequence() const operation = ber.readEnumeration() const modification = Attribute.fromBer(ber) return new Change({ operation, modification }) } } module.exports = Change