UNPKG

qunpack

Version:

unpack binary strings like perl and php

317 lines (281 loc) 13 kB
/** * unpack bytes into integers or structs of integers, * kinda like php or perl `unpack`. * * Copyright (C) 2017 Andras Radics * Licensed under the Apache License, Version 2.0 * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 * * 2017-11-25 - Started - AR. */ /* * Php pack() format described in http://php.net/manual/en/function.pack.php */ 'use strict'; module.exports = { pack: qpack, unpack: qunpack, }; function qpack( format, args ) { throw new Error("pack: not implemented."); // allocate a sufficiently large buffer, and pack into it // var endOffset = qpackInto(format, buf, 0, argv); // return buf.slice(0, endOffset); } //function qpackInto( format, buf, offset, argv ) { //} function qunpack( format, bytes, offset ) { offset = offset > 0 ? offset : 0; var state = { fmt: format, buf: bytes, fi: 0, ofs: offset, v: null, depth: 0, hashDepth: 0 }; return _qunpack(format, state); } // TODO: bounds test? (ie, if doesn't fit) // TODO: switch on charcode, not char // TODO: gather into a Retval that can be either an array or a hash. // Currently we return an array annotated with enumerable properties. // TODO: split into one-item extractor (updating state) and subgroup/hash unpack loops. // Note that the main unpack is a subgroup loop without a closing ']'. function _qunpack( format, state ) { var retArray = new Array(); state.depth += 1; var fmt, name, cnt, ch; for ( ; state.fi < format.length; ) { var skip = false; // in {#...} hashes the fmt is preceded by name: if (state.hashDepth > 0 && !name) name = scanPropertyName(format, state); // conversion specifier fmt = format[state.fi++]; // two-byte conversion specifiers if (fmt === 'Z' && format[state.fi] === '+') { fmt = 'Z+'; state.fi++ } else if (format[state.fi] === '<' && (fmt === 'C' || fmt === 'c' || fmt === 's' || fmt === 'l' || fmt === 'i' || fmt === 'q')) { fmt += '<'; state.fi++ } cnt = getCount(state); // unpack bytes according to the conversion switch (fmt) { case 'c': case 's': case 'i': case 'l': case 'q': // native signed integers case 'C': case 'S': case 'I': case 'L': case 'Q': // native unsigned integers case 'n': case 'N': case 'J': // network order (big-e) unsigned ints case 'f': case 'd': case 'G': case 'E': // native and big-e float and double for (var i=0; i<cnt; i++) { retArray.push(unpackFixedBE(fmt, state)); }; break; case 'v': case 'V': case 'P': // little-e ints case 'g': case 'e': // little-e float and double case 'c<': case 's<': case 'l<': case 'q<': // signed little-e ints (extension) case 'C<': case 'i<': for (var i=0; i<cnt; i++) { retArray.push(unpackFixedLE(fmt, state)); }; break; case 'a': case 'A': case 'Z': // byte-counted strings case 'H': case 'h': // hex strings retArray.push(unpackString(fmt, state, cnt)); break; case 'Z+': // var-length asciiz string (extension) for (var i=0; i<cnt; i++) { retArray.push(unpackString('az', state, findAsciizLength(state))); }; break; case 'x': state.ofs += cnt; break; // skip ahead case 'X': state.ofs -= cnt; break; // back up case '@': state.ofs = cnt; break; // seek to absolute case '[': // [# ... ] sub-array (extension) unpackSubgroup(retArray, cnt, state); break; case '{': // {# ... } sub-object (extension) unpackHash(retArray, cnt, state); break; case ']': case '}': if (state.depth > 1) { state.depth -= 1; return retArray; }; break; default: if (fmt >= 'a' && fmt <= 'z' || fmt >= 'A' && fmt <= 'Z') throw new Error("qunpack: unsupported conversion character '" + fmt + "'"); // ignore unrecognized punctuation. TODO: maybe error out on unknown conv specifiers? skip = true; break; } if (!skip && state.hashDepth > 0) { // assign {a:S} as a direct value, but {a:C2} as an array of 2 shorts if (retArray.length === 1) retArray[name] = retArray.pop(); else if (retArray.length) { retArray[name] = retArray.slice(0); retArray.length = 0 } // if no value generated for conversion character, do not assign to name name = null; } } state.depth -= 1; if (state.depth > 0) throw new Error("qunpack: unterminated [...] or {...} subgroup"); return retArray; } // separate function to keep the fi rewind clutter out of the main unpack loop function unpackSubgroup( retArray, cnt, state ) { if (!cnt) throw new Error("qunpack: [#...] count must be non-zero"); // gather the subgroup as when outside a hash, to not expect field names var saveHashDepth = state.hashDepth; state.hashDepth = false; var subgroupFormatIndex = state.fi; for (var i=0; i<cnt; i++) { state.fi = subgroupFormatIndex; retArray.push(_qunpack(state.fmt, state)); } state.hashDepth = saveHashDepth; } // separate function to keep the fi rewind clutter out of the main unpack loop function unpackHash( retArray, cnt, state ) { if (!cnt) throw new Error("qunpack: {#...} count must be non-zero"); var hashFormatIndex = state.fi; state.hashDepth += 1; for (var i=0; i<cnt; i++) { state.fi = hashFormatIndex; retArray.push(_qunpack(state.fmt, state)); } state.hashDepth -= 1; } /* * extract a fixed-size value from the bytes * Notes: * - a left shift of a 1 into bit position 32 makes the value negative * - a large negative eg FFFE can be built out of a scaled negative prefix FF * 256 * and and a positive additive offset FE, ie (-1 * 256) + 254 = -2. */ function unpackFixedBE( format, state ) { var val; switch (format) { case 'C': return state.buf[state.ofs++]; case 'c': return state.buf[state.ofs] >= 128 ? -256 + state.buf[state.ofs++] : state.buf[state.ofs++]; case 'S': case 'n': case 's': val = (state.buf[state.ofs++] << 8) + state.buf[state.ofs++]; return (val >= 0x8000 && (format === 's')) ? val - 0x10000 : val; case 'L': case 'I': case 'N': case 'l': case 'i': val = (state.buf[state.ofs++] * 0x1000000) + (state.buf[state.ofs++] << 16) + (state.buf[state.ofs++] << 8) + (state.buf[state.ofs++]); return (val >= 0x80000000 && (format === 'l' || format === 'i')) ? val - 0x100000000 : val; case 'Q': case 'J': case 'q': var fmt = format === 'Q' ? 'L' : 'l'; return (unpackFixedBE(fmt, state) * 0x100000000) + unpackFixedBE('L', state); case 'f': case 'G': return (state.ofs += 4, state.buf.readFloatBE(state.ofs - 4)); case 'd': case 'E': return (state.ofs += 8, state.buf.readDoubleBE(state.ofs - 8)); } } /* * extract a fixed-size little-endian value from the bytes * Note that P (little-e quadword) needs a signed little-endian 'l' format, * also implemented here. */ function unpackFixedLE( format, state ) { switch (format) { case 'C<': case 'C': return state.buf[state.ofs++]; case 'c<': case 'c': return state.buf[state.ofs] >= 128 ? -256 + state.buf[state.ofs++] : state.buf[state.ofs++]; case 's<': case 'v': case 'S': case 's': // caution: scale with multiply, not shift, highest-address byte: // (undefined << 8) == 0, (undefined * 256) == NaN, (undefined + 0) == NaN var val = (state.buf[state.ofs++]) + (state.buf[state.ofs++] * 256); return (val >= 0x8000 && (format === 's' || format === 's<')) ? val - 0x10000 : val; case 'l<': case 'i<': case 'V': case 'L': case 'I': case 'l': case 'i': val = (state.buf[state.ofs++]) + (state.buf[state.ofs++] << 8) + (state.buf[state.ofs++] << 16) + (state.buf[state.ofs++] * 0x1000000); return (val >= 0x80000000 && (format === 'l' || format === 'i' || format === 'l<' || format === 'i<')) ? val - 0x100000000 : val; case 'q<': case 'P': case 'Q': case 'q': var fmt = (format === 'q' || format === 'q<') ? 'i' : 'V'; return unpackFixedLE('V', state) + (unpackFixedLE(fmt, state) * 0x100000000); case 'g': return (state.ofs += 4, state.buf.readFloatLE(state.ofs - 4)); case 'e': return (state.ofs += 8, state.buf.readDoubleLE(state.ofs - 8)); } } // TODO: decode utf8 or ascii/latin1? Here we do utf8. // TODO: avoid depending on Buffer, use decodeUtf8 and read float/double var stringWhitespace = [ ' ', '\t', '\n', '\r', '\0' ]; // var stringWhitespaceRegex = /[ \t\n\r\0]+$/g; function unpackString( format, state, size ) { var val; if (format === 'H' || format === 'h') { // node-v0.10 rejects non-integer base and bound; later node dont mind val = state.buf.toString('hex', state.ofs, state.ofs += Math.floor((size + 1) / 2)); } else val = state.buf.toString(undefined, state.ofs, state.ofs += size); switch (format) { case 'a': return val; case 'az': // asciiz string, advance past its terminating NUL state.ofs += 1; return val; case 'A': //if (stringWhitespaceRegex.test(val)) val = val.replace(stringWhitespaceRegex, ''); for (var len = val.length; len > 0 && stringWhitespace.indexOf(val[len - 1]) >= 0; len--) ; return (len < val.length) ? val.slice(0, len) : val; case 'Z': for (var len = val.length; len > 0 && val[len - 1] === '\0'; len--) ; return (len < val.length) ? val.slice(0, len) : val; case 'H': return val.slice(0, size); case 'h': // swap nybbles in each hex byte, but low address bytes still goes first // Hex output consumes the entire last byte, even if not all its bits are converted. var hexbuf = new Buffer(val); for (var t, i=0; i<hexbuf.length; i+=2) { t = hexbuf[i]; hexbuf[i] = hexbuf[i+1]; hexbuf[i+1] = t; } return hexbuf.toString(undefined, 0, size); } } function getCount( state ) { // test '9' first, avoid the '0' test for non-numeric chars var ch = state.fmt[state.fi]; return (ch <= '9' && ch >= '0') ? scanInt(state.fmt, state) : 1; } // scan in the number in the string starting at position state.fi // Stop on non-number or end of string. function scanInt( string, state ) { var ival = 0, ch, cc, fi = state.fi; while (true) { cc = string.charCodeAt(fi); if (cc >= 0x30 && cc <= 0x39) { ival = ival * 10 + (cc - 0x30); fi++; } else { state.fi = fi; return ival; } } } // extract the colon-terminated substring starting at state.fi function scanPropertyName( format, state ) { var name, ch, hasSlash = false, fi; // advance to the start of the name // The name begins with the first char that can start a js varname, [a-zA-Z_$] while (state.fi < format.length && (ch = format[state.fi]) !== '}' && ch !== ':' && !canStartVarname(format[state.fi])) { if (format[state.fi] === '\\') state.fi++; state.fi++; } // find the ':' at the end of name: fi = state.fi; while (fi < format.length) { switch (format[fi++]) { case '}': if (fi > state.fi + 1) throwUnterminatedNameError(format, state.fi - 1); return ''; case '\\': fi++; hasSlash = true; break; case ':': var str = format.slice(state.fi, fi - 1); if (hasSlash) str = str.replace(/\\:/g, ':').replace(/\\\\/g, '\\'); state.fi = fi; return str; } } throwUnterminatedNameError(format, state.fi - 1); } function throwUnterminatedNameError( format, fi ) { throw new Error("qunpack: unterminated property name starting at " + fi + ": '" + format.slice(fi, fi + 20) + (format.length - fi > 20 ? '...' : "'")); } function canStartVarname( ch ) { // var names must start with a letter, underscore, or $ return ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch === '_') || (ch === '$')); } // count the length of the asciiz string starting at the current offset // A NUL or the end of the buffer terminate the string. function findAsciizLength( state ) { for (var pos=state.ofs; state.buf[pos]; pos++) ; return pos - state.ofs; }