'use strict'; const stream = require('stream'); const utils = require('./utils'); const Decoder = require('./decoder'); const NoFilter = require('nofilter'); const {MT, NUMBYTES, SYMS} = require('./constants'); const {Buffer} = require('buffer'); function plural(c) { if (c > 1) { return 's'; } return ''; } /** * @typedef CommentOptions * @property {number} [max_depth=10] How many times to indent * the dashes. * @property {number} [depth=1] Initial indentation depth. * @property {boolean} [no_summary=false] If true, omit the summary * of the full bytes read at the end. * @property {object} [tags] Mapping from tag number to function(v), * where v is the decoded value that comes after the tag, and where the * function returns the correctly-created value for that tag. * @property {boolean} [preferWeb=false] If true, prefer Uint8Arrays to * be generated instead of node Buffers. This might turn on some more * changes in the future, so forward-compatibility is not guaranteed yet. * @property {BufferEncoding} [encoding='hex'] Encoding to use for input, if it * is a string. */ /** * @callback commentCallback * @param {Error} [error] If one was generated. * @param {string} [commented] The comment string. * @returns {void} */ /** * Normalize inputs to the static functions. * * @param {CommentOptions|commentCallback|string|number} opts Encoding, * max_depth, or callback. * @param {commentCallback} [cb] Called on completion. * @returns {{options: CommentOptions, cb: commentCallback}} Normalized value. * @throws {TypeError} Unknown option type. * @private */ function normalizeOptions(opts, cb) { switch (typeof opts) { case 'function': return {options: {}, cb: /** @type {commentCallback} */ (opts)}; case 'string': return {options: {encoding: /** @type {BufferEncoding} */ (opts)}, cb}; case 'number': return {options: {max_depth: opts}, cb}; case 'object': return {options: opts || {}, cb}; default: throw new TypeError('Unknown option type'); } } /** * Generate the expanded format of RFC 8949, section 3.2.2. * * @extends stream.Transform */ class Commented extends stream.Transform { /** * Create a CBOR commenter. * * @param {CommentOptions} [options={}] Stream options. */ constructor(options = {}) { const { depth = 1, max_depth = 10, no_summary = false, // Decoder options tags = {}, preferWeb, encoding, // Stream.Transform options ...superOpts } = options; super({ ...superOpts, readableObjectMode: false, writableObjectMode: false, }); this.depth = depth; this.max_depth = max_depth; this.all = new NoFilter(); if (!tags[24]) { tags[24] = this._tag_24.bind(this); } this.parser = new Decoder({ tags, max_depth, preferWeb, encoding, }); this.parser.on('value', this._on_value.bind(this)); this.parser.on('start', this._on_start.bind(this)); this.parser.on('start-string', this._on_start_string.bind(this)); this.parser.on('stop', this._on_stop.bind(this)); this.parser.on('more-bytes', this._on_more.bind(this)); this.parser.on('error', this._on_error.bind(this)); if (!no_summary) { this.parser.on('data', this._on_data.bind(this)); } this.parser.bs.on('read', this._on_read.bind(this)); } /** * @param {Buffer} v Descend into embedded CBOR. * @private */ _tag_24(v) { const c = new Commented({depth: this.depth + 1, no_summary: true}); c.on('data', b => this.push(b)); c.on('error', er => this.emit('error', er)); c.end(v); } /** * Transforming. * * @param {any} fresh Buffer to transcode. * @param {BufferEncoding} encoding Name of encoding. * @param {stream.TransformCallback} cb Callback when done. * @ignore */ _transform(fresh, encoding, cb) { this.parser.write(fresh, encoding, cb); } /** * Flushing. * * @param {stream.TransformCallback} cb Callback when done. * @ignore */ _flush(cb) { // TODO: find the test that covers this, and look at the return value return this.parser._flush(cb); } /** * Comment on an input Buffer or string, creating a string passed to the * callback. If callback not specified, a promise is returned. * * @param {string|Buffer|ArrayBuffer|Uint8Array|Uint8ClampedArray * |DataView|stream.Readable} input Something to parse. * @param {CommentOptions|commentCallback|string|number} [options={}] * Encoding, max_depth, or callback. * @param {commentCallback} [cb] If specified, called on completion. * @returns {Promise} If cb not specified. * @throws {Error} Input required. */ static comment(input, options = {}, cb = null) { if (input == null) { throw new Error('input required'); } ({options, cb} = normalizeOptions(options, cb)); const bs = new NoFilter(); const {encoding = 'hex', ...opts} = options; const d = new Commented(opts); let p = null; if (typeof cb === 'function') { d.on('end', () => { cb(null, bs.toString('utf8')); }); d.on('error', cb); } else { p = new Promise((resolve, reject) => { d.on('end', () => { resolve(bs.toString('utf8')); }); d.on('error', reject); }); } d.pipe(bs); utils.guessEncoding(input, encoding).pipe(d); return p; } /** * @ignore */ _on_error(er) { this.push('ERROR: '); this.push(er.toString()); this.push('\n'); } /** * @ignore */ _on_read(buf) { this.all.write(buf); const hex = buf.toString('hex'); this.push(new Array(this.depth + 1).join(' ')); this.push(hex); let ind = ((this.max_depth - this.depth) * 2) - hex.length; if (ind < 1) { ind = 1; } this.push(new Array(ind + 1).join(' ')); this.push('-- '); } /** * @ignore */ _on_more(mt, len, _parent_mt, _pos) { let desc = ''; this.depth++; switch (mt) { case MT.POS_INT: desc = 'Positive number,'; break; case MT.NEG_INT: desc = 'Negative number,'; break; case MT.ARRAY: desc = 'Array, length'; break; case MT.MAP: desc = 'Map, count'; break; case MT.BYTE_STRING: desc = 'Bytes, length'; break; case MT.UTF8_STRING: desc = 'String, length'; break; case MT.SIMPLE_FLOAT: if (len === 1) { desc = 'Simple value,'; } else { desc = 'Float,'; } break; } this.push(`${desc} next ${len} byte${plural(len)}\n`); } /** * @ignore */ _on_start_string(mt, len, _parent_mt, _pos) { let desc = ''; this.depth++; switch (mt) { case MT.BYTE_STRING: desc = `Bytes, length: ${len}`; break; case MT.UTF8_STRING: desc = `String, length: ${len.toString()}`; break; } this.push(`${desc}\n`); } /** * @ignore */ _on_start(mt, tag, parent_mt, pos) { this.depth++; switch (parent_mt) { case MT.ARRAY: this.push(`[${pos}], `); break; case MT.MAP: if (pos % 2) { this.push(`{Val:${Math.floor(pos / 2)}}, `); } else { this.push(`{Key:${Math.floor(pos / 2)}}, `); } break; } switch (mt) { case MT.TAG: this.push(`Tag #${tag}`); if (tag === 24) { this.push(' Encoded CBOR data item'); } break; case MT.ARRAY: if (tag === SYMS.STREAM) { this.push('Array (streaming)'); } else { this.push(`Array, ${tag} item${plural(tag)}`); } break; case MT.MAP: if (tag === SYMS.STREAM) { this.push('Map (streaming)'); } else { this.push(`Map, ${tag} pair${plural(tag)}`); } break; case MT.BYTE_STRING: this.push('Bytes (streaming)'); break; case MT.UTF8_STRING: this.push('String (streaming)'); break; } this.push('\n'); } /** * @ignore */ _on_stop(_mt) { this.depth--; } /** * @private */ _on_value(val, parent_mt, pos, ai) { if (val !== SYMS.BREAK) { switch (parent_mt) { case MT.ARRAY: this.push(`[${pos}], `); break; case MT.MAP: if (pos % 2) { this.push(`{Val:${Math.floor(pos / 2)}}, `); } else { this.push(`{Key:${Math.floor(pos / 2)}}, `); } break; } } const str = utils.cborValueToString(val, -Infinity); if ((typeof val === 'string') || (Buffer.isBuffer(val))) { if (val.length > 0) { this.push(str); this.push('\n'); } this.depth--; } else { this.push(str); this.push('\n'); } switch (ai) { case NUMBYTES.ONE: case NUMBYTES.TWO: case NUMBYTES.FOUR: case NUMBYTES.EIGHT: this.depth--; } } /** * @ignore */ _on_data() { this.push('0x'); this.push(this.all.read().toString('hex')); this.push('\n'); } } module.exports = Commented;