From 56eec1de7018759c0ec251dba4455c18f73c3bbd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 20 Nov 2021 07:01:42 +0100 Subject: Adding upstream version 0.1.10+dfsg. Signed-off-by: Daniel Baumann --- src/encode.js | 124 ++++ src/text.js | 33 + src/zcrc.js | 143 +++++ src/zdle.js | 240 +++++++ src/zerror.js | 47 ++ src/zheader.js | 763 ++++++++++++++++++++++ src/zmlib.js | 102 +++ src/zmodem.js | 4 + src/zmodem_browser.js | 182 ++++++ src/zsentry.js | 394 ++++++++++++ src/zsession.js | 1677 +++++++++++++++++++++++++++++++++++++++++++++++++ src/zsubpacket.js | 241 +++++++ src/zvalidation.js | 130 ++++ 13 files changed, 4080 insertions(+) create mode 100644 src/encode.js create mode 100644 src/text.js create mode 100644 src/zcrc.js create mode 100644 src/zdle.js create mode 100644 src/zerror.js create mode 100644 src/zheader.js create mode 100644 src/zmlib.js create mode 100644 src/zmodem.js create mode 100644 src/zmodem_browser.js create mode 100644 src/zsentry.js create mode 100644 src/zsession.js create mode 100644 src/zsubpacket.js create mode 100644 src/zvalidation.js (limited to 'src') diff --git a/src/encode.js b/src/encode.js new file mode 100644 index 0000000..5cb6344 --- /dev/null +++ b/src/encode.js @@ -0,0 +1,124 @@ +"use strict"; + +var Zmodem = module.exports; + +const HEX_DIGITS = [ 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102 ]; + +const HEX_OCTET_VALUE = {}; +for (var hd=0; hd 0xffff) throw( "Number cannot exceed 16 bits: " + number ) + + return [ number >> 8, number & 0xff ]; + }, + + /** + * Return an array with the given number as 4 little-endian bytes. + * + * @param {number} number - The number to encode. + * + * @returns {number[]} The octet values. + */ + pack_u32_le: function pack_u32_le(number) { + //Can’t bit-shift because that runs into JS’s bit-shift problem. + //(See _updcrc32() for an example.) + var high_bytes = number / 65536; //fraction is ok + + //a little-endian 4-byte sequence + return [ + number & 0xff, + (number & 65535) >> 8, + high_bytes & 0xff, + high_bytes >> 8, + ]; + }, + + /** + * The inverse of pack_u16_be() - i.e., take in 2 octet values + * and parse them as an unsigned, 2-byte big-endian number. + * + * @param {number[]} octets - The octet values (2 of them). + * + * @returns {number} The decoded number. + */ + unpack_u16_be: function unpack_u16_be(bytes_arr) { + return (bytes_arr[0] << 8) + bytes_arr[1]; + }, + + /** + * The inverse of pack_u32_le() - i.e., take in a 4-byte sequence + * and parse it as an unsigned, 4-byte little-endian number. + * + * @param {number[]} octets - The octet values (4 of them). + * + * @returns {number} The decoded number. + */ + unpack_u32_le: function unpack_u32_le(octets) { + // … (254 << 24 is -33554432, according to JavaScript) + return octets[0] + (octets[1] << 8) + (octets[2] << 16) + (octets[3] * 16777216); + }, + + /** + * Encode a series of octet values to be the octet values that + * correspond to the ASCII hex characters for each octet. The + * returned array is suitable for use as binary data. + * + * For example: + * + * Original Hex Returned + * 254 fe 102, 101 + * 12 0c 48, 99 + * 129 81 56, 49 + * + * @param {number[]} octets - The original octet values. + * + * @returns {number[]} The octet values that correspond to an ASCII + * representation of the given octets. + */ + octets_to_hex: function octets_to_hex(octets) { + var hex = []; + for (var o=0; o> 4 ], + HEX_DIGITS[ octets[o] & 0x0f ] + ); + } + + return hex; + }, + + /** + * The inverse of octets_to_hex(): takes an array + * of hex octet pairs and returns their octet values. + * + * @param {number[]} hex_octets - The hex octet values. + * + * @returns {number[]} The parsed octet values. + */ + parse_hex_octets: function parse_hex_octets(hex_octets) { + var octets = new Array(hex_octets.length / 2); + + for (var i=0; i> 8) & 255)] + ^ ((255 & crc) << 8) + ^ cp + ); +} + +function __verify(expect, got) { + var err; + + if ( expect.join() !== got.join() ) { + throw new Zmodem.Error("crc", got, expect); + } +} + +//TODO: use external implementation(s) +Zmodem.CRC = { + + //https://www.lammertbies.nl/comm/info/crc-calculation.html + //CRC-CCITT (XModem) + + /** + * Deduce a given set of octet values’ CRC16, as per the CRC16 + * variant that ZMODEM uses (CRC-CCITT/XModem). + * + * @param {Array} octets - The array of octet values. + * Each array member should be an 8-bit unsigned integer (0-255). + * + * @returns {Array} crc - The CRC, expressed as an array of octet values. + */ + crc16: function crc16(octet_nums) { + var crc = octet_nums[0]; + for (var b=1; b>> 0 //bit-shift to get unsigned + ); + }, + + /** + * Verify a given set of octet values’ CRC16. + * An exception is thrown on failure. + * + * @param {Array} bytes_arr - The array of octet values. + * Each array member should be an 8-bit unsigned integer (0-255). + * + * @param {Array} crc - The CRC to check against, expressed as + * an array of octet values. + */ + verify16: function verify16(bytes_arr, got) { + return __verify( this.crc16(bytes_arr), got ); + }, + + /** + * Verify a given set of octet values’ CRC32. + * An exception is thrown on failure. + * + * @param {Array} bytes_arr - The array of octet values. + * Each array member should be an 8-bit unsigned integer (0-255). + * + * @param {Array} crc - The CRC to check against, expressed as + * an array of octet values. + */ + verify32: function verify32(bytes_arr, crc) { + try { + __verify( this.crc32(bytes_arr), crc ); + } + catch(err) { + err.input = bytes_arr.slice(0); + throw err; + } + }, +}; diff --git a/src/zdle.js b/src/zdle.js new file mode 100644 index 0000000..989222e --- /dev/null +++ b/src/zdle.js @@ -0,0 +1,240 @@ +"use strict"; + +var Zmodem = module.exports; + +Object.assign( + Zmodem, + require("./zmlib") +); + +//encode() variables - declare them here so we don’t +//create them in the function. +var encode_cur, encode_todo; + +const ZDLE = Zmodem.ZMLIB.ZDLE; + +/** + * Class that handles ZDLE encoding and decoding. + * Encoding is subject to a given configuration--specifically, whether + * we want to escape all control characters. Decoding is static; however + * a given string is encoded we can always decode it. + */ +Zmodem.ZDLE = class ZmodemZDLE { + /** + * Create a ZDLE encoder. + * + * @param {object} [config] - The initial configuration. + * @param {object} config.escape_ctrl_chars - Whether the ZDLE encoder + * should escape control characters. + */ + constructor(config) { + this._config = {}; + if (config) { + this.set_escape_ctrl_chars(!!config.escape_ctrl_chars); + } + } + + /** + * Enable or disable control-character escaping. + * You should probably enable this for sender sessions. + * + * @param {boolean} value - Whether to enable (true) or disable (false). + */ + set_escape_ctrl_chars(value) { + if (typeof value !== "boolean") throw "need boolean!"; + + if (value !== this._config.escape_ctrl_chars) { + this._config.escape_ctrl_chars = value; + this._setup_zdle_table(); + } + } + + /** + * Whether or not control-character escaping is enabled. + * + * @return {boolean} Whether the escaping is on (true) or off (false). + */ + escapes_ctrl_chars() { + return !!this._config.escape_ctrl_chars; + } + + //I don’t know of any Zmodem implementations that use ZESC8 + //(“escape_8th_bit”)?? + + /* + ZMODEM software escapes ZDLE, 020, 0220, 021, 0221, 023, and 0223. If + preceded by 0100 or 0300 (@), 015 and 0215 are also escaped to protect the + Telenet command escape CR-@-CR. + */ + + /** + * Encode an array of octet values and return it. + * This will mutate the given array. + * + * @param {number[]} octets - The octet values to transform. + * Each array member should be an 8-bit unsigned integer (0-255). + * This object is mutated in the function. + * + * @returns {number[]} The passed-in array, transformed. This is the + * same object that is passed in. + */ + encode(octets) { + //NB: Performance matters here! + + if (!this._zdle_table) throw "No ZDLE encode table configured!"; + + var zdle_table = this._zdle_table; + + var last_code = this._lastcode; + + var arrbuf = new ArrayBuffer( 2 * octets.length ); + var arrbuf_uint8 = new Uint8Array(arrbuf); + + var escctl_yn = this._config.escape_ctrl_chars; + + var arrbuf_i = 0; + + for (encode_cur=0; encode_cur=0; o--) { + if (octets[o] === ZDLE) { + octets.splice( o, 2, octets[o+1] - 64 ); + } + } + + return octets; + } + + /** + * Remove, ZDLE-decode, and return bytes from the passed-in array. + * If the requested number of ZDLE-encoded bytes isn’t available, + * then the passed-in array is unmodified (and the return is undefined). + * + * @param {number[]} octets - The octet values to transform. + * Each array member should be an 8-bit unsigned integer (0-255). + * This object is mutated in the function. + * + * @param {number} offset - The number of (undecoded) bytes to skip + * at the beginning of the “octets” array. + * + * @param {number} count - The number of bytes (octet values) to return. + * + * @returns {number[]|undefined} An array with the requested number of + * decoded octet values, or undefined if that number of decoded + * octets isn’t available (given the passed-in offset). + */ + static splice(octets, offset, count) { + var so_far = 0; + + if (!offset) offset = 0; + + for (var i = offset; i> 8, + 0, + flags_num, + ]; + } + + //undefined if nonstop I/O is allowed + get_buffer_size() { + return Zmodem.ENCODELIB.unpack_u16_be( this._bytes4.slice(0, 2) ) || undefined; + } + + //Unimplemented: + // can_decrypt + // can_decompress + + //---------------------------------------------------------------------- + //function names taken from Jacques Mattheij’s implementation, + //as used in syncterm. + + can_full_duplex() { + return !!( this._bytes4[3] & ZRINIT_FLAG.CANFDX ); + } + + can_overlap_io() { + return !!( this._bytes4[3] & ZRINIT_FLAG.CANOVIO ); + } + + can_break() { + return !!( this._bytes4[3] & ZRINIT_FLAG.CANBRK ); + } + + can_fcs_32() { + return !!( this._bytes4[3] & ZRINIT_FLAG.CANFC32 ); + } + + escape_ctrl_chars() { + return !!( this._bytes4[3] & ZRINIT_FLAG.ESCCTL ); + } + + //Is this used? I don’t see it used in lrzsz or syncterm + //Looks like it was a “foreseen” feature that Forsberg + //never implemented. (The need for it went away, maybe?) + escape_8th_bit() { + return !!( this._bytes4[3] & ZRINIT_FLAG.ESC8 ); + } +}; + +//---------------------------------------------------------------------- + +//Since context makes clear what’s going on, we use these +//rather than the T-prefixed constants in the specification. +const ZSINIT_FLAG = { + ESCCTL: 0x40, // Transmitter will escape ctl chars + ESC8: 0x80, // Transmitter will escape 8th bit +}; + +function _get_ZSINIT_flag_num(fl) { + if (!ZSINIT_FLAG[fl]) { + throw("Invalid ZSINIT flag: " + fl); + } + return ZSINIT_FLAG[fl]; +} + +class ZSINIT_HEADER extends Zmodem.Header { + constructor( flags_arr, attn_seq_arr ) { + super(); + var flags_num = 0; + + flags_arr.forEach( function(fl) { + flags_num |= _get_ZSINIT_flag_num(fl); + } ); + + this._bytes4 = [ 0, 0, 0, flags_num ]; + + if (attn_seq_arr) { + if (attn_seq_arr.length > 31) { + throw("Attn sequence must be <= 31 bytes"); + } + if (attn_seq_arr.some( function(num) { return num > 255 } )) { + throw("Attn sequence (" + attn_seq_arr + ") must be <256"); + } + this._data = attn_seq_arr.concat([0]); + } + } + + escape_ctrl_chars() { + return !!( this._bytes4[3] & ZSINIT_FLAG.ESCCTL ); + } + + //Is this used? I don’t see it used in lrzsz or syncterm + escape_8th_bit() { + return !!( this._bytes4[3] & ZSINIT_FLAG.ESC8 ); + } +} + +//Thus far it doesn’t seem we really need this header except to respond +//to ZSINIT, which doesn’t require a payload. +class ZACK_HEADER extends Zmodem.Header { + constructor(payload4) { + super(); + + if (payload4) { + this._bytes4 = payload4.slice(); + } + } +} +ZACK_HEADER.prototype._hex_header_ending = HEX_HEADER_CRLF; + +//---------------------------------------------------------------------- + +const ZFILE_VALUES = { + + //ZF3 (i.e., first byte) + extended: { + sparse: 0x40, //ZXSPARS + }, + + //ZF2 + transport: [ + undefined, + "compress", //ZTLZW + "encrypt", //ZTCRYPT + "rle", //ZTRLE + ], + + //ZF1 + management: [ + undefined, + "newer_or_longer", //ZF1_ZMNEWL + "crc", //ZF1_ZMCRC + "append", //ZF1_ZMAPND + "clobber", //ZF1_ZMCLOB + "newer", //ZF1_ZMNEW + "mtime_or_length", //ZF1_ZMNEW + "protect", //ZF1_ZMPROT + "rename", //ZF1_ZMPROT + ], + + //ZF0 (i.e., last byte) + conversion: [ + undefined, + "binary", //ZCBIN + "text", //ZCNL + "resume", //ZCRESUM + ], +}; + +const ZFILE_ORDER = ["extended", "transport", "management", "conversion"]; + +const ZMSKNOLOC = 0x80, + MANAGEMENT_MASK = 0x1f, + ZXSPARS = 0x40 +; + +class ZFILE_HEADER extends Zmodem.Header { + + //TODO: allow options on instantiation + get_options() { + var opts = { + sparse: !!(this._bytes4[0] & ZXSPARS), + }; + + var bytes_copy = this._bytes4.slice(0); + + ZFILE_ORDER.forEach( function(key, i) { + if (ZFILE_VALUES[key] instanceof Array) { + if (key === "management") { + opts.skip_if_absent = !!(bytes_copy[i] & ZMSKNOLOC); + bytes_copy[i] &= MANAGEMENT_MASK; + } + + opts[key] = ZFILE_VALUES[key][ bytes_copy[i] ]; + } + else { + for (var extkey in ZFILE_VALUES[key]) { + opts[extkey] = !!(bytes_copy[i] & ZFILE_VALUES[key][extkey]); + if (opts[extkey]) { + bytes_copy[i] ^= ZFILE_VALUES[key][extkey] + } + } + } + + if (!opts[key] && bytes_copy[i]) { + opts[key] = "unknown:" + bytes_copy[i]; + } + } ); + + return opts; + } +} + +//---------------------------------------------------------------------- + +//Empty headers - in addition to ZRQINIT +class ZSKIP_HEADER extends Zmodem.Header {} +//No need for ZNAK +class ZABORT_HEADER extends Zmodem.Header {} +class ZFIN_HEADER extends Zmodem.Header {} +class ZFERR_HEADER extends Zmodem.Header {} + +ZFIN_HEADER.prototype._hex_header_ending = HEX_HEADER_CRLF; + +class ZOffsetHeader extends Zmodem.Header { + constructor(offset) { + super(); + this._bytes4 = Zmodem.ENCODELIB.pack_u32_le(offset); + } + + get_offset() { + return Zmodem.ENCODELIB.unpack_u32_le(this._bytes4); + } +} + +class ZRPOS_HEADER extends ZOffsetHeader {}; +class ZDATA_HEADER extends ZOffsetHeader {}; +class ZEOF_HEADER extends ZOffsetHeader {}; + +//As request, receiver creates. +/* UNIMPLEMENTED FOR NOW +class ZCRC_HEADER extends ZHeader { + constructor(crc_le_bytes) { + super(); + if (crc_le_bytes) { //response, sender creates + this._bytes4 = crc_le_bytes; + } + } +} +*/ + +//No ZCHALLENGE implementation + +//class ZCOMPL_HEADER extends ZHeader {} +//class ZCAN_HEADER extends Zmodem.Header {} + +//As described, this header represents an information disclosure. +//It could be interpreted, I suppose, merely as “this is how much space +//I have FOR YOU.” +//TODO: implement if needed/requested +//class ZFREECNT_HEADER extends ZmodemHeader {} + +//---------------------------------------------------------------------- + +const FRAME_CLASS_TYPES = [ + [ ZRQINIT_HEADER, "ZRQINIT" ], + [ ZRINIT_HEADER, "ZRINIT" ], + [ ZSINIT_HEADER, "ZSINIT" ], + [ ZACK_HEADER, "ZACK" ], + [ ZFILE_HEADER, "ZFILE" ], + [ ZSKIP_HEADER, "ZSKIP" ], + undefined, // [ ZNAK_HEADER, "ZNAK" ], + [ ZABORT_HEADER, "ZABORT" ], + [ ZFIN_HEADER, "ZFIN" ], + [ ZRPOS_HEADER, "ZRPOS" ], + [ ZDATA_HEADER, "ZDATA" ], + [ ZEOF_HEADER, "ZEOF" ], + [ ZFERR_HEADER, "ZFERR" ], //see note + undefined, //[ ZCRC_HEADER, "ZCRC" ], + undefined, //[ ZCHALLENGE_HEADER, "ZCHALLENGE" ], + undefined, //[ ZCOMPL_HEADER, "ZCOMPL" ], + undefined, //[ ZCAN_HEADER, "ZCAN" ], + undefined, //[ ZFREECNT_HEADER, "ZFREECNT" ], + undefined, //[ ZCOMMAND_HEADER, "ZCOMMAND" ], + undefined, //[ ZSTDERR_HEADER, "ZSTDERR" ], +]; + +/* +ZFERR is described as “error in reading or writing file”. It’s really +not a good idea from a security angle for the endpoint to expose this +information. We should parse this and handle it as ZABORT but never send it. + +Likewise with ZFREECNT: the sender shouldn’t ask how much space is left +on the other box; rather, the receiver should decide what to do with the +file size as the sender reports it. +*/ + +var FRAME_NAME_CREATOR = {}; + +for (var fc=0; fc 11) { + hdr_err = "Invalid hex header - no LF detected within 12 bytes!"; + } + + //incomplete header + return; + } + else { + hex_bytes = bytes_arr.splice( 0, lf_pos ); + + //Trim off the LF + bytes_arr.shift(); + + if ( hex_bytes.length === 19 ) { + + //NB: The spec says CR but seems to treat high-bit variants + //of control characters the same as the regulars; should we + //also allow 0x8d? + var preceding = hex_bytes.pop(); + if ( preceding !== 0x0d && preceding !== 0x8d ) { + hdr_err = "Invalid hex header: (CR/)LF doesn’t have CR!"; + } + } + else if ( hex_bytes.length !== 18 ) { + hdr_err = "Invalid hex header: invalid number of bytes before LF!"; + } + } + + if (hdr_err) { + hdr_err += " (" + hex_bytes.length + " bytes: " + hex_bytes.join() + ")"; + throw hdr_err; + } + + hex_bytes.splice(0, 4); + + //Should be 7 bytes ultimately: + // 1 for typenum + // 4 for header data + // 2 for CRC + var octets = Zmodem.ENCODELIB.parse_hex_octets(hex_bytes); + + return _parse_non_zdle_binary16(octets); +} + +Zmodem.Header.parse_hex = _parse_hex; diff --git a/src/zmlib.js b/src/zmlib.js new file mode 100644 index 0000000..f86070d --- /dev/null +++ b/src/zmlib.js @@ -0,0 +1,102 @@ +"use strict"; + +var Zmodem = module.exports; + +const + ZDLE = 0x18, + XON = 0x11, + XOFF = 0x13, + XON_HIGH = 0x80 | XON, + XOFF_HIGH = 0x80 | XOFF, + CAN = 0x18 //NB: same character as ZDLE +; + +/** + * Tools and constants that are useful for ZMODEM. + * + * @exports ZMLIB + */ +Zmodem.ZMLIB = { + + /** + * @property {number} The ZDLE constant, which ZMODEM uses for escaping + */ + ZDLE: ZDLE, + + /** + * @property {number} XON - ASCII XON + */ + XON: XON, + + /** + * @property {number} XOFF - ASCII XOFF + */ + XOFF: XOFF, + + /** + * @property {number[]} ABORT_SEQUENCE - ZMODEM’s abort sequence + */ + ABORT_SEQUENCE: [ CAN, CAN, CAN, CAN, CAN ], + + /** + * Remove octet values from the given array that ZMODEM always ignores. + * This will mutate the given array. + * + * @param {number[]} octets - The octet values to transform. + * Each array member should be an 8-bit unsigned integer (0-255). + * This object is mutated in the function. + * + * @returns {number[]} The passed-in array. This is the same object that is + * passed in. + */ + strip_ignored_bytes: function strip_ignored_bytes(octets) { + for (var o=octets.length-1; o>=0; o--) { + switch (octets[o]) { + case XON: + case XON_HIGH: + case XOFF: + case XOFF_HIGH: + octets.splice(o, 1); + continue; + } + } + + return octets; + }, + + /** + * Like Array.prototype.indexOf, but searches for a subarray + * rather than just a particular value. + * + * @param {Array} haystack - The array to search, i.e., the bigger. + * + * @param {Array} needle - The array whose values to find, + * i.e., the smaller. + * + * @returns {number} The position in “haystack” where “needle” + * first appears—or, -1 if “needle” doesn’t appear anywhere + * in “haystack”. + */ + find_subarray: function find_subarray(haystack, needle) { + var h=0, n; + + var start = Date.now(); + + HAYSTACK: + while (h !== -1) { + h = haystack.indexOf( needle[0], h ); + if (h === -1) break HAYSTACK; + + for (n=1; n=0; f--) { + var fobj = files[f]; + total_size += fobj.size; + batch[f] = { + obj: fobj, + name: fobj.name, + size: fobj.size, + mtime: new Date(fobj.lastModified), + files_remaining: files.length - f, + bytes_remaining: total_size, + }; + } + + var file_idx = 0; + function promise_callback() { + var cur_b = batch[file_idx]; + + if (!cur_b) { + return Promise.resolve(); //batch done! + } + + file_idx++; + + return session.send_offer(cur_b).then( function after_send_offer(xfer) { + if (options.on_offer_response) { + options.on_offer_response(cur_b.obj, xfer); + } + + if (xfer === undefined) { + return promise_callback(); //skipped + } + + return new Promise( function(res) { + var reader = new FileReader(); + + //This really shouldn’t happen … so let’s + //blow up if it does. + reader.onerror = function reader_onerror(e) { + console.error("file read error", e); + throw("File read error: " + e); + }; + + var piece; + reader.onprogress = function reader_onprogress(e) { + + //Some browsers (e.g., Chrome) give partial returns, + //while others (e.g., Firefox) don’t. + if (e.target.result) { + piece = new Uint8Array(e.target.result, xfer.get_offset()) + + _check_aborted(session); + + xfer.send(piece); + + if (options.on_progress) { + options.on_progress(cur_b.obj, xfer, piece); + } + } + }; + + reader.onload = function reader_onload(e) { + piece = new Uint8Array(e.target.result, xfer, piece) + + _check_aborted(session); + + xfer.end(piece).then( function() { + if (options.on_progress && piece.length) { + options.on_progress(cur_b.obj, xfer, piece); + } + + if (options.on_file_complete) { + options.on_file_complete(cur_b.obj, xfer); + } + + //Resolve the current file-send promise with + //another promise. That promise resolves immediately + //if we’re done, or with another file-send promise + //if there’s more to send. + res( promise_callback() ); + } ); + }; + + reader.readAsArrayBuffer(cur_b.obj); + } ); + } ); + } + + return promise_callback(); + }, + + /** + * Prompt a user to save the given packets as a file by injecting an + * `` element (with `display: none` styling) into the page and + * calling the element’s `click()` + * method. The element is removed immediately after. + * + * @param {Array} packets - Same as the first argument to [Blob’s constructor](https://developer.mozilla.org/en-US/docs/Web/API/Blob). + * @param {string} name - The name to give the file. + */ + save_to_disk: function save_to_disk(packets, name) { + var blob = new Blob(packets); + var url = URL.createObjectURL(blob); + + var el = document.createElement("a"); + el.style.display = "none"; + el.href = url; + el.download = name; + document.body.appendChild(el); + + //It seems like a security problem that this actually works; + //I’d think there would need to be some confirmation before + //a browser could save arbitrarily many bytes onto the disk. + //But, hey. + el.click(); + + document.body.removeChild(el); + }, +}; diff --git a/src/zsentry.js b/src/zsentry.js new file mode 100644 index 0000000..470769a --- /dev/null +++ b/src/zsentry.js @@ -0,0 +1,394 @@ +"use strict"; + +var Zmodem = module.exports; + +Object.assign( + Zmodem, + require("./zmlib"), + require("./zsession") +); + +const + MIN_ZM_HEX_START_LENGTH = 20, + MAX_ZM_HEX_START_LENGTH = 21, + + // **, ZDLE, 'B0' + //ZRQINIT’s next byte will be '0'; ZRINIT’s will be '1'. + COMMON_ZM_HEX_START = [ 42, 42, 24, 66, 48 ], + + SENTRY_CONSTRUCTOR_REQUIRED_ARGS = [ + "to_terminal", + "on_detect", + "on_retract", + "sender", + ], + + ASTERISK = 42 +; + +/** + * An instance of this object is passed to the Sentry’s on_detect + * callback each time the Sentry object sees what looks like the + * start of a ZMODEM session. + * + * Note that it is possible for a detection to be “retracted” + * if the Sentry consumes bytes afterward that are not ZMODEM. + * When this happens, the Sentry’s `retract` event will fire, + * after which the Detection object is no longer usable. + */ +class Detection { + + /** + * Not called directly. + */ + constructor(session_type, accepter, denier, checker) { + + //confirm() - user confirms that ZMODEM is desired + this._confirmer = accepter; + + //deny() - user declines ZMODEM; send abort sequence + // + //TODO: It might be ideal to forgo the session “peaceably”, + //i.e., such that the peer doesn’t end in error. That’s + //possible if we’re the sender, we accept the session, + //then we just send a close(), but it doesn’t seem to be + //possible for a receiver. Thus, let’s just leave it so + //it’s at least consistent (and simpler, too). + this._denier = denier; + + this._is_valid = checker; + + this._session_type = session_type; + } + + /** + * Confirm that the detected ZMODEM sequence indicates the + * start of a ZMODEM session. + * + * @return {Session} The ZMODEM Session object (i.e., either a + * Send or Receive instance). + */ + confirm() { + return this._confirmer.apply(this, arguments); + } + + /** + * Tell the Sentry that the detected bytes sequence is + * **NOT** intended to be the start of a ZMODEM session. + */ + deny() { + return this._denier.apply(this, arguments); + } + + /** + * Tells whether the Detection is still valid; i.e., whether + * the Sentry has `consume()`d bytes that invalidate the + * Detection. + * + * @returns {boolean} Whether the Detection is valid. + */ + is_valid() { + return this._is_valid.apply(this, arguments); + } + + /** + * Gives the session’s role. + * + * @returns {string} One of: + * - `receive` + * - `send` + */ + get_session_role() { return this._session_type } +} + +/** + * Class that parses an input stream for the beginning of a + * ZMODEM session. We look for the tell-tale signs + * of a ZMODEM transfer and allow the client to determine whether + * it’s really ZMODEM or not. + * + * This is the “mother” class for zmodem.js; + * all other class instances are created, directly or indirectly, + * by an instance of this class. + * + * This logic is not unlikely to need tweaking, and it can never + * be fully bulletproof; if it could be bulletproof it would be + * simpler since there wouldn’t need to be the .confirm()/.deny() + * step. + * + * One thing you could do to make things a bit simpler *is* just + * to make that assumption for your users--i.e., to .confirm() + * Detection objects automatically. That’ll be one less step + * for the user, but an unaccustomed user might find that a bit + * confusing. It’s also then possible to have a “false positive”: + * a text stream that contains a ZMODEM initialization string but + * isn’t, in fact, meant to start a ZMODEM session. + * + * Workflow: + * - parse all input with .consume(). As long as nothing looks + * like ZMODEM, all the traffic will go to to_terminal(). + * + * - when a “tell-tale” sequence of bytes arrives, we create a + * Detection object and pass it to the “on_detect” handler. + * + * - Either .confirm() or .deny() with the Detection object. + * This is the user’s chance to say, “yeah, I know those + * bytes look like ZMODEM, but they’re not. So back off!” + * + * If you .confirm(), the Session object is returned, and + * further input that goes to the Sentry’s .consume() will + * go to the (now-active) Session object. + * + * - Sometimes additional traffic arrives that makes it apparent + * that no ZMODEM session is intended to start; in this case, + * the Sentry marks the Detection as “stale” and calls the + * `on_retract` handler. Any attempt from here to .confirm() + * on the Detection object will prompt an exception. + * + * (This “retraction” behavior will only happen prior to + * .confirm() or .deny() being called on the Detection object. + * Beyond that point, either the Session has to deal with the + * “garbage”, or it’s back to the terminal anyway. + * + * - Once the Session object is done, the Sentry will again send + * all traffic to to_terminal(). + */ +Zmodem.Sentry = class ZmodemSentry { + + /** + * Invoked directly. Creates a new Sentry that inspects all + * traffic before it goes to the terminal. + * + * @param {Object} options - The Sentry parameters + * + * @param {Function} options.to_terminal - Handler that sends + * traffic to the terminal object. Receives an iterable object + * (e.g., an Array) that contains octet numbers. + * + * @param {Function} options.on_detect - Handler for new + * detection events. Receives a new Detection object. + * + * @param {Function} options.on_retract - Handler for retraction + * events. Receives no input. + * + * @param {Function} options.sender - Handler that sends traffic to + * the peer. If, for example, your application uses WebSocket to talk + * to the peer, use this to send data to the WebSocket instance. + */ + constructor(options) { + if (!options) throw "Need options!"; + + var sentry = this; + SENTRY_CONSTRUCTOR_REQUIRED_ARGS.forEach( function(arg) { + if (!options[arg]) { + throw "Need “" + arg + "”!"; + } + sentry["_" + arg] = options[arg]; + } ); + + this._cache = []; + } + + _after_session_end() { + this._zsession = null; + } + + /** + * “Consumes” a piece of input: + * + * - If there is no active or pending ZMODEM session, the text is + * all output. (This is regardless of whether we’ve got a new + * Detection.) + * + * - If there is no active ZMODEM session and the input **ends** with + * a ZRINIT or ZRQINIT, then a new Detection object is created, + * and it is passed to the “on_detect” function. + * If there was another pending Detection object, it is retracted. + * + * - If there is no active ZMODEM session and the input does NOT end + * with a ZRINIT or ZRQINIT, then any pending Detection object is + * retracted. + * + * - If there is an active ZMODEM session, the input is passed to it. + * Any non-ZMODEM data (i.e., “garbage”) parsed from the input + * is sent to output. + * If the ZMODEM session ends, any post-ZMODEM part of the input + * is sent to output. + * + * @param {number[] | ArrayBuffer} input - Octets to parse as input. + */ + consume(input) { + if (!(input instanceof Array)) { + input = Array.prototype.slice.call( new Uint8Array(input) ); + } + + if (this._zsession) { + var session_before_consume = this._zsession; + + session_before_consume.consume(input); + + if (session_before_consume.has_ended()) { + if (session_before_consume.type === "receive") { + input = session_before_consume.get_trailing_bytes(); + } + else { + input = []; + } + } + else return; + } + + var new_session = this._parse(input); + var to_terminal = input; + + if (new_session) { + let replacement_detect = !!this._parsed_session; + + if (replacement_detect) { + //no terminal output if the new session is of the + //same type as the old + if (this._parsed_session.type === new_session.type) { + to_terminal = []; + } + + this._on_retract(); + } + + this._parsed_session = new_session; + + var sentry = this; + + function checker() { + return sentry._parsed_session === new_session; + } + + //This runs with the Sentry object as the context. + function accepter() { + if (!this.is_valid()) { + throw "Stale ZMODEM session!"; + } + + new_session.on("garbage", sentry._to_terminal); + + new_session.on( + "session_end", + sentry._after_session_end.bind(sentry) + ); + + new_session.set_sender(sentry._sender); + + delete sentry._parsed_session; + + return sentry._zsession = new_session; + }; + + function denier() { + if (!this.is_valid()) return; + }; + + this._on_detect( new Detection( + new_session.type, + accepter, + this._send_abort.bind(this), + checker + ) ); + } + else { + /* + if (this._parsed_session) { + this._session_stale_because = 'Non-ZMODEM output received after ZMODEM initialization.'; + } + */ + + var expired_session = this._parsed_session; + + this._parsed_session = null; + + if (expired_session) { + + //If we got a single “C” after parsing a session, + //that means our peer is trying to downgrade to YMODEM. + //That won’t work, so we just send the ABORT_SEQUENCE + //right away. + if (to_terminal.length === 1 && to_terminal[0] === 67) { + this._send_abort(); + } + + this._on_retract(); + } + } + + this._to_terminal(to_terminal); + } + + /** + * @return {Session|null} The sentry’s current Session object, or + * null if there is none. + */ + get_confirmed_session() { + return this._zsession || null; + } + + _send_abort() { + this._sender( Zmodem.ZMLIB.ABORT_SEQUENCE ); + } + + /** + * Parse an input stream and decide how much of it goes to the + * terminal or to a new Session object. + * + * This will accommodate input strings that are fragmented + * across calls to this function; e.g., if you send the first + * two bytes at the end of one parse() call then send the rest + * at the beginning of the next, parse() will recognize it as + * the beginning of a ZMODEM session. + * + * In order to keep from blocking any actual useful data to the + * terminal in real-time, this will send on the initial + * ZRINIT/ZRQINIT bytes to the terminal. They’re meant to go to the + * terminal anyway, so that should be fine. + * + * @private + * + * @param {Array|Uint8Array} array_like - The input bytes. + * Each member should be a number between 0 and 255 (inclusive). + * + * @return {Array} A two-member list: + * 0) the bytes that should be printed on the terminal + * 1) the created Session object (if any) + */ + _parse(array_like) { + var cache = this._cache; + + cache.push.apply( cache, array_like ); + + while (true) { + let common_hex_at = Zmodem.ZMLIB.find_subarray( cache, COMMON_ZM_HEX_START ); + if (-1 === common_hex_at) break; + + let before_common_hex = cache.splice(0, common_hex_at); + let zsession; + try { + zsession = Zmodem.Session.parse(cache); + } catch(err) { //ignore errors + //console.log(err); + } + + if (!zsession) break; + + //Don’t need to parse the trailing XON. + if ((cache.length === 1) && (cache[0] === Zmodem.ZMLIB.XON)) { + cache.shift(); + } + + //If there are still bytes in the cache, + //then we don’t have a ZMODEM session. This logic depends + //on the sender only sending one initial header. + return cache.length ? null : zsession; + } + + cache.splice( MAX_ZM_HEX_START_LENGTH ); + + return null; + } +} diff --git a/src/zsession.js b/src/zsession.js new file mode 100644 index 0000000..5f0b8f9 --- /dev/null +++ b/src/zsession.js @@ -0,0 +1,1677 @@ +"use strict"; + +var Zmodem = module.exports; + +/** + * This is where the protocol-level logic lives: the interaction of ZMODEM + * headers and subpackets. The logic here is not unlikely to need tweaking + * as little edge cases crop up. + */ + +Zmodem.DEBUG = false; + +Object.assign( + Zmodem, + require("./encode"), + require("./text"), + require("./zdle"), + require("./zmlib"), + require("./zheader"), + require("./zsubpacket"), + require("./zvalidation"), + require("./zerror") +); + +const + //pertinent to this module + KEEPALIVE_INTERVAL = 5000, + + //We ourselves don’t need ESCCTL, so we don’t send it; + //however, we always expect to receive it in ZRINIT. + //See _ensure_receiver_escapes_ctrl_chars() for more details. + ZRINIT_FLAGS = [ + "CANFDX", //full duplex + "CANOVIO", //overlap I/O + + //lsz has a buffer overflow bug that shows itself when: + // + // - 16-bit CRC is used, and + // - lsz receives the abort sequence while sending a file + // + //To avoid this, we just tell lsz to use 32-bit CRC + //even though there is otherwise no reason. This ensures that + //unfixed lsz versions will avoid the buffer overflow. + "CANFC32", + ], + + //We do this because some WebSocket shell servers + //(e.g., xterm.js’s demo server) enable the IEXTEN termios flag, + //which bars 0x0f and 0x16 from reaching the shell process, + //which results in transmission errors. + FORCE_ESCAPE_CTRL_CHARS = true, + + DEFAULT_RECEIVE_INPUT_MODE = "spool_uint8array", + + //pertinent to ZMODEM + MAX_CHUNK_LENGTH = 8192, //1 KiB officially, but lrzsz allows 8192 + BS = 0x8, + OVER_AND_OUT = [ 79, 79 ], + ABORT_SEQUENCE = Zmodem.ZMLIB.ABORT_SEQUENCE +; + +/** + * A base class for objects that have events. + * + * @private + */ +class _Eventer { + + /** + * Not called directly. + */ + constructor() { + this._on_evt = {}; + this._evt_once_index = {}; + } + + _Add_event(evt_name) { + this._on_evt[evt_name] = []; + this._evt_once_index[evt_name] = []; + } + + _get_evt_queue(evt_name) { + if (!this._on_evt[evt_name]) { + throw( "Bad event: " + evt_name ); + } + + return this._on_evt[evt_name]; + } + + /** + * Register a callback for a given event. + * + * @param {string} evt_name - The name of the event. + * + * @param {Function} todo - The function to execute when the event happens. + */ + on(evt_name, todo) { + var queue = this._get_evt_queue(evt_name); + + queue.push(todo); + + return this; + } + + /** + * Unregister a callback for a given event. + * + * @param {string} evt_name - The name of the event. + * + * @param {Function} [todo] - The function to execute when the event + * happens. If not given, the last event registered for the event + * is unregistered. + */ + off(evt_name, todo) { + var queue = this._get_evt_queue(evt_name); + + if (todo) { + var at = queue.indexOf(todo); + if (at === -1) { + throw("“" + todo + "” is not in the “" + evt_name + "” queue."); + } + queue.splice(at, 1); + } + else { + queue.pop(); + } + + return this; + } + + _Happen(evt_name /*, arg0, arg1, .. */) { + var queue = this._get_evt_queue(evt_name); //might as well validate + + //console.info("EVENT", this, arguments); + + var args = Array.apply(null, arguments); + args.shift(); + + var sess = this; + + queue.forEach( function(cb) { cb.apply(sess, args) } ); + + return queue.length; + } +} + +/** + * The Session classes handle the protocol-level logic. + * These shield the user from dealing with headers and subpackets. + * This is a base class with functionality common to both Receive + * and Send subclasses. + * + * @extends _Eventer +*/ +Zmodem.Session = class ZmodemSession extends _Eventer { + + /** + * Parse out a hex header from the given array. + * If there’s a ZRQINIT or ZRINIT at the beginning, + * we’ll return it. If the input isn’t a header, + * for whatever reason, we return undefined. + * + * @param {number[]} octets - The bytes to parse. + * + * @return {Session|undefined} A Session object if the beginning + * of a session was parsable in “octets”; otherwise undefined. + */ + static parse( octets ) { + + //Will need to trap errors. + var hdr; + try { + hdr = Zmodem.Header.parse_hex(octets); + } + catch(e) { //Don’t report since we aren’t in session + + //debug + //console.warn("No hex header: ", e); + + return; + } + + if (!hdr) return; + + switch (hdr.NAME) { + case "ZRQINIT": + //throw if ZCOMMAND + return new Zmodem.Session.Receive(); + case "ZRINIT": + return new Zmodem.Session.Send(hdr); + } + + //console.warn("Invalid first Zmodem header", hdr); + } + + /** + * Sets the sender function that a Session object will use. + * + * @param {Function} sender_func - The function to call. + * It will receive an Array with the relevant octets. + * + * @return {Session} The session object (for chaining). + */ + set_sender(sender_func) { + this._sender = sender_func; + return this; + } + + /** + * Whether the current Session has ended. + * + * @returns {boolean} The ended state. + */ + has_ended() { return this._has_ended() } + + /** + * Consumes an array of octets as ZMODEM session input. + * + * @param {number[]} octets - The input octets. + */ + consume(octets) { + this._before_consume(octets); + + if (this._aborted) throw new Zmodem.Error('already_aborted'); + + if (!octets.length) return; + + this._strip_and_enqueue_input(octets); + + if (!this._check_for_abort_sequence(octets)) { + this._consume_first(); + } + + return; + } + + /** + * Whether the current Session has been `abort()`ed. + * + * @returns {boolean} The aborted state. + */ + aborted() { return !!this._aborted } + + /** + * Not called directly. + */ + constructor() { + super(); + //if (!sender_func) throw "Need sender!"; + + //this._first_header = first_header; + //this._sender = sender_func; + this._config = {}; + + //this._input = new ZInput(); + + this._input_buffer = []; + + //This is mostly for debugging. + this._Add_event("receive"); + this._Add_event("garbage"); + this._Add_event("session_end"); + } + + /** + * Returns the Session object’s role. + * + * @returns {string} One of: + * - `receive` + * - `send` + */ + get_role() { return this.type } + + _trim_leading_garbage_until_header() { + var garbage = Zmodem.Header.trim_leading_garbage(this._input_buffer); + + if (garbage.length) { + if (this._Happen("garbage", garbage) === 0) { + console.debug( + "Garbage: ", + String.fromCharCode.apply(String, garbage), + garbage + ); + } + } + } + + _parse_and_consume_header() { + this._trim_leading_garbage_until_header(); + + var new_header_and_crc = Zmodem.Header.parse(this._input_buffer); + if (!new_header_and_crc) return; + + if (Zmodem.DEBUG) { + this._log_header( "RECEIVED HEADER", new_header_and_crc[0] ); + } + + this._consume_header(new_header_and_crc[0]); + + this._last_header_name = new_header_and_crc[0].NAME; + this._last_header_crc = new_header_and_crc[1]; + + return new_header_and_crc[0]; + } + + _log_header(label, header) { + console.debug(this.type, label, header.NAME, header._bytes4.join()); + } + + _consume_header(new_header) { + this._on_receive(new_header); + + var handler = this._next_header_handler && this._next_header_handler[ new_header.NAME ]; + if (!handler) { + console.error("Unhandled header!", new_header, this._next_header_handler); + throw new Zmodem.Error( "Unhandled header: " + new_header.NAME ); + } + + this._next_header_handler = null; + + handler.call(this, new_header); + } + + //TODO: strip out the abort sequence + _check_for_abort_sequence() { + var abort_at = Zmodem.ZMLIB.find_subarray( this._input_buffer, ABORT_SEQUENCE ); + + if (abort_at !== -1) { + + //TODO: expose this to caller + this._input_buffer.splice( 0, abort_at + ABORT_SEQUENCE.length ); + + this._aborted = true; + + //TODO compare response here to lrzsz. + this._on_session_end(); + + //We shouldn’t ever expect to receive an abort. Even if we + //have sent an abort ourselves, the Sentry should have stopped + //directing input to this Session object. + //if (this._expect_abort) { + // return true; + //} + + throw new Zmodem.Error("peer_aborted"); + } + } + + _send_header(name /*, args */) { + if (!this._sender) throw "Need sender!"; + + var args = Array.apply( null, arguments ); + + var bytes_hdr = this._create_header_bytes(args); + + if (Zmodem.DEBUG) { + this._log_header( "SENDING HEADER", bytes_hdr[1] ); + } + + this._sender(bytes_hdr[0]); + + this._last_sent_header = bytes_hdr[1]; + } + + _create_header_bytes(name_and_args) { + + var hdr = Zmodem.Header.build.apply( Zmodem.Header, name_and_args ); + + var formatter = this._get_header_formatter(name_and_args[0]); + + return [ + hdr[formatter](this._zencoder), + hdr + ]; + } + + _strip_and_enqueue_input(input) { + Zmodem.ZMLIB.strip_ignored_bytes(input); + + //It’s possible that “input” is empty at this point. + //It doesn’t seem to hurt anything to keep processing, though. + + this._input_buffer.push.apply( this._input_buffer, input ); + } + + /** + * **STOP!** You probably want to `skip()` an Offer rather than + * `abort()`. See below. + * + * Abort the current session by sending the ZMODEM abort sequence. + * This function will cause the Session object to refuse to send + * any further data. + * + * Zmodem.Sentry is configured to send all output to the terminal + * after a session’s `abort()`. That could result in lots of + * ZMODEM garble being sent to the JavaScript terminal, which you + * probably don’t want. + * + * `skip()` on an Offer is better because Session will continue to + * discard data until we reach either another file or the + * sender-initiated end of the ZMODEM session. So no ZMODEM garble, + * and the session will end successfully. + * + * The behavior of `abort()` is subject to change since it’s not + * very useful as currently implemented. + */ + abort() { + + //this._expect_abort = true; + + //From Forsberg: + // + //The Cancel sequence consists of eight CAN characters + //and ten backspace characters. ZMODEM only requires five + //Cancel characters; the other three are "insurance". + //The trailing backspace characters attempt to erase + //the effects of the CAN characters if they are + //received by a command interpreter. + // + //FG: Since we assume our connection is reliable, there’s + //no reason to send more than 5 CANs. + this._sender( + ABORT_SEQUENCE.concat([ BS, BS, BS, BS, BS ]) + ); + + this._aborted = true; + this._sender = function() { + throw new Zmodem.Error('already_aborted'); + }; + + this._on_session_end(); + + return; + } + + //---------------------------------------------------------------------- + _on_session_end() { + this._Happen("session_end"); + } + + _on_receive(hdr_or_pkt) { + this._Happen("receive", hdr_or_pkt); + } + + _before_consume() {} +} + +function _trim_OO(array) { + if (0 === Zmodem.ZMLIB.find_subarray(array, OVER_AND_OUT)) { + array.splice(0, OVER_AND_OUT.length); + } + + //TODO: This assumes OVER_AND_OUT is 2 bytes long. No biggie, but. + else if ( array[0] === OVER_AND_OUT[ OVER_AND_OUT.length - 1 ] ) { + array.splice(0, 1); + } + + return array; +} + +/** A class for ZMODEM receive sessions. + * + * @extends Session + */ +Zmodem.Session.Receive = class ZmodemReceiveSession extends Zmodem.Session { + //We only get 1 file at a time, so on each consume() either + //continue state for the current file or start a new one. + + /** + * Not called directly. + */ + constructor() { + super(); + + this._Add_event("offer"); + this._Add_event("data_in"); + this._Add_event("file_end"); + } + + /** + * Consume input bytes from the sender. + * + * @private + * @param {number[]} octets - The bytes to consume. + */ + _before_consume(octets) { + if (this._bytes_after_OO) { + throw "PROTOCOL: Session is completed!"; + } + + //Put this here so that our logic later on has access to the + //input string and can populate _bytes_after_OO when the + //session ends. + this._bytes_being_consumed = octets; + } + + /** + * Return any bytes that have been `consume()`d but + * came after the end of the ZMODEM session. + * + * @returns {number[]} The trailing bytes. + */ + get_trailing_bytes() { + if (this._aborted) return []; + + if (!this._bytes_after_OO) { + throw "PROTOCOL: Session is not completed!"; + } + + return this._bytes_after_OO.slice(0); + } + + _has_ended() { return this.aborted() || !!this._bytes_after_OO } + + //Receiver always sends hex headers. + _get_header_formatter() { return "to_hex" } + + _parse_and_consume_subpacket() { + var parse_func; + if (this._last_header_crc === 16) { + parse_func = "parse16"; + } + else { + parse_func = "parse32"; + } + + var subpacket = Zmodem.Subpacket[parse_func](this._input_buffer); + + if (subpacket) { + if (Zmodem.DEBUG) { + console.debug(this.type, "RECEIVED SUBPACKET", subpacket); + } + + this._consume_data(subpacket); + + //What state are we in if the subpacket indicates frame end + //but we haven’t gotten ZEOF yet? Can anything other than ZEOF + //follow after a ZDATA? + if (subpacket.frame_end()) { + this._next_subpacket_handler = null; + } + } + + return subpacket; + } + + _consume_first() { + if (this._got_ZFIN) { + if (this._input_buffer.length < 2) return; + + //if it’s OO, then set this._bytes_after_OO + if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) === 0) { + + //This doubles as an indication that the session has ended. + //We need to set this right away so that handlers like + //"session_end" will have access to it. + this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0)); + this._on_session_end(); + + return; + } + else { + throw( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() ); + } + } + + var parsed; + do { + if (this._next_subpacket_handler) { + parsed = this._parse_and_consume_subpacket(); + } + else { + parsed = this._parse_and_consume_header(); + } + } while (parsed && this._input_buffer.length); + } + + _consume_data(subpacket) { + this._on_receive(subpacket); + + if (!this._next_subpacket_handler) { + throw( "PROTOCOL: Received unexpected data packet after " + this._last_header_name + " header: " + subpacket.get_payload().join() ); + } + + this._next_subpacket_handler.call(this, subpacket); + } + + _octets_to_string(octets) { + if (!this._textdecoder) { + this._textdecoder = new Zmodem.Text.Decoder(); + } + + return this._textdecoder.decode( new Uint8Array(octets) ); + } + + _consume_ZFILE_data(hdr, subpacket) { + if (this._file_info) { + throw "PROTOCOL: second ZFILE data subpacket received"; + } + + var packet_payload = subpacket.get_payload(); + var nul_at = packet_payload.indexOf(0); + + // + var fname = this._octets_to_string( packet_payload.slice(0, nul_at) ); + var the_rest = this._octets_to_string( packet_payload.slice( 1 + nul_at ) ).split(" "); + + var mtime = the_rest[1] && parseInt( the_rest[1], 8 ) || undefined; + if (mtime) { + mtime = new Date(mtime * 1000); + } + + this._file_info = { + name: fname, + size: the_rest[0] ? parseInt( the_rest[0], 10 ) : null, + mtime: mtime || null, + mode: the_rest[2] && parseInt( the_rest[2], 8 ) || null, + serial: the_rest[3] && parseInt( the_rest[3], 10 ) || null, + + files_remaining: the_rest[4] ? parseInt( the_rest[4], 10 ) : null, + bytes_remaining: the_rest[5] ? parseInt( the_rest[5], 10 ) : null, + }; + + //console.log("ZFILE", hdr); + + var xfer = new Offer( + hdr.get_options(), + this._file_info, + this._accept.bind(this), + this._skip.bind(this) + ); + this._current_transfer = xfer; + + //this._Happen("offer", xfer); + } + + _consume_ZDATA_data(subpacket) { + if (!this._accepted_offer) { + throw "PROTOCOL: Received data without accepting!"; + } + + //TODO: Probably should include some sort of preventive against + //infinite loop here: if the peer hasn’t sent us what we want after, + //say, 10 ZRPOS headers then we should send ZABORT and just end. + if (!this._offset_ok) { + console.warn("offset not ok!"); + _send_ZRPOS(); + return; + } + + this._file_offset += subpacket.get_payload().length; + this._on_data_in(subpacket); + + /* + console.warn("received error from data_in callback; retrying", e); + throw "unimplemented"; + */ + + if (subpacket.ack_expected() && !subpacket.frame_end()) { + this._send_header( "ZACK", Zmodem.ENCODELIB.pack_u32_le(this._file_offset) ); + } + } + + _make_promise_for_between_files() { + var sess = this; + + return new Promise( function(res) { + var between_files_handler = { + ZFILE: function(hdr) { + this._next_subpacket_handler = function(subpacket) { + this._next_subpacket_handler = null; + this._consume_ZFILE_data(hdr, subpacket); + this._Happen("offer", this._current_transfer); + res(this._current_transfer); + }; + }, + + //We use this as a keep-alive. Maybe other + //implementations do, too? + ZSINIT: function(hdr) { + //The content of this header doesn’t affect us + //since all it does is tell us details of how + //the sender will ZDLE-encode binary data. Our + //ZDLE parser doesn’t need to know in advance. + + sess._next_subpacket_handler = function(spkt) { + sess._next_subpacket_handler = null; + sess._consume_ZSINIT_data(spkt); + sess._send_header('ZACK'); + sess._next_header_handler = between_files_handler; + }; + }, + + ZFIN: function() { + this._consume_ZFIN(); + res(); + }, + }; + + sess._next_header_handler = between_files_handler; + } ); + } + + _consume_ZSINIT_data(spkt) { + + //TODO: Should this be used when we signal a cancellation? + this._attn = spkt.get_payload(); + } + + /** + * Start the ZMODEM session by signaling to the sender that + * we are ready for the first file offer. + * + * @returns {Promise} A promise that resolves with an Offer object + * or, if the sender closes the session immediately without offering + * anything, nothing. + */ + start() { + if (this._started) throw "Already started!"; + this._started = true; + + var ret = this._make_promise_for_between_files(); + + this._send_ZRINIT(); + + return ret; + } + + //Returns a promise that’s fulfilled when the file + //transfer is done. + // + // That ZEOF promise return is another promise that’s + // fulfilled when we get either ZFIN or another ZFILE. + _accept(offset) { + this._accepted_offer = true; + this._file_offset = offset || 0; + + var sess = this; + + var ret = new Promise( function(resolve_accept) { + var last_ZDATA; + + sess._next_header_handler = { + ZDATA: function on_ZDATA(hdr) { + this._consume_ZDATA(hdr); + + this._next_subpacket_handler = this._consume_ZDATA_data; + + this._next_header_handler = { + ZEOF: function on_ZEOF(hdr) { + + // Do this first to verify the ZEOF. + // This also fires the “file_end” event. + this._consume_ZEOF(hdr); + + this._next_subpacket_handler = null; + + // We don’t care about this promise. + // Prior to v0.1.8 we did because we called + // resolve_accept() at the resolution of this + // promise, but that was a bad idea and was + // never documented, so 0.1.8 changed it. + this._make_promise_for_between_files(); + + resolve_accept(); + + this._send_ZRINIT(); + }, + }; + }, + }; + } ); + + this._send_ZRPOS(); + + return ret; + } + + _skip() { + var ret = this._make_promise_for_between_files(); + + if (this._accepted_offer) { + // There’s a race condition where we might attempt to + // skip() an in-progress transfer near its end but actually + // the skip() will fire after the transfer is complete. + // While there might be ways to prevent this, they likely + // would require extra work on the part of implementations. + // + // It seems far simpler just to make this function a no-op + // in these cases. + if (!this._current_transfer) return; + + //For cancel of an in-progress transfer from lsz, + //it’s necessary to avoid this buffer overflow bug: + // + // https://github.com/gooselinux/lrzsz/blob/master/lrzsz-0.12.20.patch + // + //… which we do by asking for CRC32 from lsz. + + //We might or might not have consumed ZDATA. + //The sender also might or might not send a ZEOF before it + //parses the ZSKIP. Thus, we want to ignore the following: + // + // - ZDATA + // - ZDATA then ZEOF + // - ZEOF + // + //… and just look for the next between-file header. + + var bound_make_promise_for_between_files = function() { + + //Once this happens we fail on any received data packet. + //So it needs not to happen until we’ve received a header. + this._accepted_offer = false; + this._next_subpacket_handler = null; + + this._make_promise_for_between_files(); + }.bind(this); + + Object.assign( + this._next_header_handler, + { + ZEOF: bound_make_promise_for_between_files, + ZDATA: function() { + bound_make_promise_for_between_files(); + this._next_header_handler.ZEOF = bound_make_promise_for_between_files; + }.bind(this), + } + ); + } + + //this._accepted_offer = false; + + this._file_info = null; + + this._send_header( "ZSKIP" ); + + return ret; + } + + _send_ZRINIT() { + this._send_header( "ZRINIT", ZRINIT_FLAGS ); + } + + _consume_ZFIN() { + this._got_ZFIN = true; + this._send_header( "ZFIN" ); + } + + _consume_ZEOF(header) { + if (this._file_offset !== header.get_offset()) { + throw( "ZEOF offset mismatch; unimplemented (local: " + this._file_offset + "; ZEOF: " + header.get_offset() + ")" ); + } + + this._on_file_end(); + + //Preserve these two so that file_end callbacks + //will have the right information. + this._file_info = null; + this._current_transfer = null; + } + + _consume_ZDATA(header) { + if ( this._file_offset === header.get_offset() ) { + this._offset_ok = true; + } + else { + throw "Error correction is unimplemented."; + } + } + + _send_ZRPOS() { + this._send_header( "ZRPOS", this._file_offset ); + } + + //---------------------------------------------------------------------- + //events + + _on_file_end() { + this._Happen("file_end"); + + if (this._current_transfer) { + this._current_transfer._Happen("complete"); + this._current_transfer = null; + } + } + + _on_data_in(subpacket) { + this._Happen("data_in", subpacket); + + if (this._current_transfer) { + this._current_transfer._Happen("input", subpacket.get_payload()); + } + } +} + +Object.assign( + Zmodem.Session.Receive.prototype, + { + type: "receive", + } +); + +//---------------------------------------------------------------------- + +/** + * @typedef {Object} FileDetails + * + * @property {string} name - The name of the file. + * + * @property {number} [size] - The file size, in bytes. + * + * @property {number} [mode] - The file mode (e.g., 0100644). + * + * @property {Date|number} [mtime] - The file’s modification time. + * When expressed as a number, the unit is epoch seconds. + * + * @property {number} [files_remaining] - Inclusive of the current file, + * so this value is never less than 1. + * + * @property {number} [bytes_remaining] - Inclusive of the current file. + */ + +/** + * Common methods for Transfer and Offer objects. + * + * @mixin + */ +var Transfer_Offer_Mixin = { + /** + * Returns the file details object. + * @returns {FileDetails} `mtime` is a Date. + */ + get_details: function get_details() { + return Object.assign( {}, this._file_info ); + }, + + /** + * Returns a parse of the ZFILE header’s payload. + * + * @returns {Object} Members are: + * + * - `conversion` (string | undefined) + * - `management` (string | undefined) + * - `transfer` (string | undefined) + * - `sparse` (boolean) + */ + get_options: function get_options() { + return Object.assign( {}, this._zfile_opts ); + }, + + /** + * Returns the offset based on the last transferred chunk. + * @returns {number} The file offset (i.e., number of bytes after + * the start of the file). + */ + get_offset: function get_offset() { + return this._file_offset; + }, +}; + +/** + * A class to represent a sender’s interaction with a single file + * transfer within a batch. When a receiver accepts an offer, the + * Session instantiates this class and passes the instance as the + * promise resolution from send_offer(). + * + * @mixes Transfer_Offer_Mixin + */ +class Transfer { + + /** + * Not called directly. + */ + constructor(file_info, offset, send_func, end_func) { + this._file_info = file_info; + this._file_offset = offset || 0; + + this._send = send_func; + this._end = end_func; + } + + /** + * Send a (non-terminal) piece of the file. + * + * @param { number[] | Uint8Array } array_like - The bytes to send. + */ + send(array_like) { + this._send(array_like); + this._file_offset += array_like.length; + } + + /** + * Complete the file transfer. + * + * @param { number[] | Uint8Array } [array_like] - The last bytes to send. + * + * @return { Promise } Resolves when the receiver has indicated + * acceptance of the end of the file transfer. + */ + end(array_like) { + var ret = this._end(array_like || []); + if (array_like) this._file_offset += array_like.length; + return ret; + } +} +Object.assign( Transfer.prototype, Transfer_Offer_Mixin ); + +/** + * A class to represent a receiver’s interaction with a single file + * transfer offer within a batch. There is functionality here to + * skip or accept offered files and either to spool the packet + * payloads or to handle them yourself. + * + * @mixes Transfer_Offer_Mixin + */ +class Offer extends _Eventer { + + /** + * Not called directly. + */ + constructor(zfile_opts, file_info, accept_func, skip_func) { + super(); + + this._zfile_opts = zfile_opts; + this._file_info = file_info; + + this._accept_func = accept_func; + this._skip_func = skip_func; + + this._Add_event("input"); + this._Add_event("complete"); + + //Register this first so that application handlers receive + //the updated offset. + this.on("input", this._input_handler); + } + + _verify_not_skipped() { + if (this._skipped) { + throw new Zmodem.Error("Already skipped!"); + } + } + + /** + * Tell the sender that you don’t want the offered file. + * + * You can send this in lieu of `accept()` or after it, e.g., + * if you find that the transfer is taking too long. Note that, + * if you `skip()` after you `accept()`, you’ll likely have to + * wait for buffers to clear out. + * + */ + skip() { + this._verify_not_skipped(); + this._skipped = true; + + return this._skip_func.apply(this, arguments); + } + + /** + * Tell the sender to send the offered file. + * + * @param {Object} [opts] - Can be: + * @param {string} [opts.oninput=spool_uint8array] - Can be: + * + * - `spool_uint8array`: Stores the ZMODEM + * packet payloads as Uint8Array instances. + * This makes for an easy transition to a Blob, + * which JavaScript can use to save the file to disk. + * + * - `spool_array`: Stores the ZMODEM packet payloads + * as Array instances. Each value is an octet value. + * + * - (function): A handler that receives each payload + * as it arrives. The Offer object does not store + * the payloads internally when thus configured. + * + * @return { Promise } Resolves when the file is fully received. + * If the Offer has been spooling + * the packet payloads, the promise resolves with an Array + * that contains those payloads. + */ + accept(opts) { + this._verify_not_skipped(); + + if (this._accepted) { + throw new Zmodem.Error("Already accepted!"); + } + this._accepted = true; + + if (!opts) opts = {}; + + this._file_offset = opts.offset || 0; + + switch (opts.on_input) { + case null: + case undefined: + case "spool_array": + case DEFAULT_RECEIVE_INPUT_MODE: //default + this._spool = []; + break; + default: + if (typeof opts.on_input !== "function") { + throw "Invalid “on_input”: " + opts.on_input; + } + } + + this._input_handler_mode = opts.on_input || DEFAULT_RECEIVE_INPUT_MODE; + + return this._accept_func(this._file_offset).then( this._get_spool.bind(this) ); + } + + _input_handler(payload) { + this._file_offset += payload.length; + + if (typeof this._input_handler_mode === "function") { + this._input_handler_mode(payload); + } + else { + if (this._input_handler_mode === DEFAULT_RECEIVE_INPUT_MODE) { + payload = new Uint8Array(payload); + } + + //sanity + else if (this._input_handler_mode !== "spool_array") { + throw new Zmodem.Error("WTF?? _input_handler_mode = " + this._input_handler_mode); + } + + this._spool.push(payload); + } + } + + _get_spool() { + return this._spool; + } +} +Object.assign( Offer.prototype, Transfer_Offer_Mixin ); + +//Curious that ZSINIT isn’t here … but, lsz sends it as hex. +const SENDER_BINARY_HEADER = { + ZFILE: true, + ZDATA: true, +}; + +/** + * A class that encapsulates behavior for a ZMODEM sender. + * + * @extends Session + */ +Zmodem.Session.Send = class ZmodemSendSession extends Zmodem.Session { + + /** + * Not called directly. + */ + constructor(zrinit_hdr) { + super(); + + if (!zrinit_hdr) { + throw "Need first header!"; + } + else if (zrinit_hdr.NAME !== "ZRINIT") { + throw("First header should be ZRINIT, not " + zrinit_hdr.NAME); + } + + this._last_header_name = 'ZRINIT'; + + //We don’t need to send crc32. Even if the other side can grok it, + //there’s no point to sending it since, for now, we assume we’re + //on a reliable connection, e.g., TCP. Ideally we’d just forgo + //CRC checks completely, but ZMODEM doesn’t allow that. + // + //If we *were* to start using crc32, we’d update this every time + //we send a header. + this._subpacket_encode_func = 'encode16'; + + this._zencoder = new Zmodem.ZDLE(); + + this._consume_ZRINIT(zrinit_hdr); + + this._file_offset = 0; + + var zrqinit_count = 0; + + this._start_keepalive_on_set_sender = true; + + //lrzsz will send ZRINIT until it gets an offer. (keep-alive?) + //It sends 4 additional ones after the initial ZRINIT and, if + //no response is received, starts sending “C” (0x43, 67) as if to + //try to downgrade to XMODEM or YMODEM. + //var sess = this; + //this._prepare_to_receive_ZRINIT( function keep_alive() { + // sess._prepare_to_receive_ZRINIT(keep_alive); + //} ); + + //queue up the ZSINIT flag to send -- but seems useless?? + + /* + Object.assign( + this._on_evt, + { + file_received: [], + }, + }; + */ + } + + /** + * Sets the sender function. The first time this is called, + * it will also initiate a keepalive using ZSINIT until the + * first file is sent. + * + * @param {Function} func - The function to call. + * It will receive an Array with the relevant octets. + * + * @return {Session} The session object (for chaining). + */ + set_sender(func) { + super.set_sender(func); + + if (this._start_keepalive_on_set_sender) { + this._start_keepalive_on_set_sender = false; + this._start_keepalive(); + } + + return this; + } + + //7.3.3 .. The sender also uses hex headers when they are + //not followed by binary data subpackets. + // + //FG: … or when the header is ZSINIT? That’s what lrzsz does, anyway. + //Then it sends a single NUL byte as the payload to an end_ack subpacket. + _get_header_formatter(name) { + return SENDER_BINARY_HEADER[name] ? "to_binary16" : "to_hex"; + } + + //In order to keep lrzsz from timing out, we send ZSINIT every 5 seconds. + //Maybe make this configurable? + _start_keepalive() { + //if (this._keepalive_promise) throw "Keep-alive already started!"; + if (!this._keepalive_promise) { + var sess = this; + + this._keepalive_promise = new Promise(function(resolve) { + //console.log("SETTING KEEPALIVE TIMEOUT"); + sess._keepalive_timeout = setTimeout(resolve, KEEPALIVE_INTERVAL); + }).then( function() { + sess._next_header_handler = { + ZACK: function() { + + //We’re going to need to ensure that the + //receiver is ready for all control characters + //to be escaped. If we’ve already sent a ZSINIT + //and gotten a response, then we know that that + //work is already done later on when we actually + //send an offer. + sess._got_ZSINIT_ZACK = true; + }, + }; + sess._send_ZSINIT(); + + sess._keepalive_promise = null; + sess._start_keepalive(); + }); + } + } + + _stop_keepalive() { + if (this._keepalive_promise) { + //console.log("STOPPING KEEPALIVE"); + clearTimeout(this._keepalive_timeout); + this._keep_alive_promise = null; + } + } + + _send_ZSINIT() { + //See note at _ensure_receiver_escapes_ctrl_chars() + //for why we have to pass ESCCTL. + + var zsinit_flags = []; + if (this._zencoder.escapes_ctrl_chars()) { + zsinit_flags.push("ESCCTL"); + } + + this._send_header_and_data( + ["ZSINIT", zsinit_flags], + [0], + "end_ack" + ); + } + + _consume_ZRINIT(hdr) { + this._last_ZRINIT = hdr; + + if (hdr.get_buffer_size()) { + throw( "Buffer size (" + hdr.get_buffer_size() + ") is unsupported!" ); + } + + if (!hdr.can_full_duplex()) { + throw( "Half-duplex I/O is unsupported!" ); + } + + if (!hdr.can_overlap_io()) { + throw( "Non-overlap I/O is unsupported!" ); + } + + if (hdr.escape_8th_bit()) { + throw( "8-bit escaping is unsupported!" ); + } + + if (FORCE_ESCAPE_CTRL_CHARS) { + this._zencoder.set_escape_ctrl_chars(true); + if (!hdr.escape_ctrl_chars()) { + console.debug("Peer didn’t request escape of all control characters. Will send ZSINIT to force recognition of escaped control characters."); + } + } + else { + this._zencoder.set_escape_ctrl_chars(hdr.escape_ctrl_chars()); + } + } + + //https://stackoverflow.com/questions/23155939/missing-0xf-and-0x16-when-binary-data-through-virtual-serial-port-pair-created-b + //^^ Because of that, we always escape control characters. + //The alternative would be that lrz would never receive those + //two bytes from zmodem.js. + _ensure_receiver_escapes_ctrl_chars() { + var promise; + + var needs_ZSINIT = !this._last_ZRINIT.escape_ctrl_chars() && !this._got_ZSINIT_ZACK; + + if (needs_ZSINIT) { + var sess = this; + promise = new Promise( function(res) { + sess._next_header_handler = { + ZACK: (hdr) => { + res(); + }, + }; + sess._send_ZSINIT(); + } ); + } + else { + promise = Promise.resolve(); + } + + return promise; + } + + _convert_params_to_offer_payload_array(params) { + params = Zmodem.Validation.offer_parameters(params); + + var subpacket_payload = params.name + "\x00"; + + var subpacket_space_pieces = [ + (params.size || 0).toString(10), + params.mtime ? params.mtime.toString(8) : "0", + params.mode ? (0x8000 | params.mode).toString(8) : "0", + "0", //serial + ]; + + if (params.files_remaining) { + subpacket_space_pieces.push( params.files_remaining ); + + if (params.bytes_remaining) { + subpacket_space_pieces.push( params.bytes_remaining ); + } + } + + subpacket_payload += subpacket_space_pieces.join(" "); + return this._string_to_octets(subpacket_payload); + } + + /** + * Send an offer to the receiver. + * + * @param {FileDetails} params - All about the file you want to transfer. + * + * @returns {Promise} If the receiver accepts the offer, then the + * resolution is a Transfer object; otherwise the resolution is + * undefined. + */ + send_offer(params) { + if (Zmodem.DEBUG) { + console.debug("SENDING OFFER", params); + } + + if (!params) throw "need file params!"; + + if (this._sending_file) throw "Already sending file!"; + + var payload_array = this._convert_params_to_offer_payload_array(params); + + this._stop_keepalive(); + + var sess = this; + + function zrpos_handler_setter_func() { + sess._next_header_handler = { + + // The receiver may send ZRPOS in at least two cases: + // + // 1) A malformed subpacket arrived, so we need to + // “rewind” a bit and continue from the receiver’s + // last-successful location in the file. + // + // 2) The receiver hasn’t gotten any data for a bit, + // so it sends ZRPOS as a “ping”. + // + // Case #1 shouldn’t happen since zmodem.js requires a + // reliable transport. Case #2, though, can happen due + // to either normal network congestion or errors in + // implementation. In either case, there’s nothing for + // us to do but to ignore the ZRPOS, with an optional + // warning. + // + ZRPOS: function(hdr) { + if (Zmodem.DEBUG) { + console.warn("Mid-transfer ZRPOS … implementation error?"); + } + + zrpos_handler_setter_func(); + }, + }; + }; + + var doer_func = function() { + + //return Promise object that is fulfilled when the ZRPOS or ZSKIP arrives. + //The promise value is the byte offset, or undefined for ZSKIP. + //If ZRPOS arrives, then send ZDATA(0) and set this._sending_file. + var handler_setter_promise = new Promise( function(res) { + sess._next_header_handler = { + ZSKIP: function() { + sess._start_keepalive(); + res(); + }, + ZRPOS: function(hdr) { + sess._sending_file = true; + + zrpos_handler_setter_func(); + + res( + new Transfer( + params, + hdr.get_offset(), + sess._send_interim_file_piece.bind(sess), + sess._end_file.bind(sess) + ) + ); + }, + }; + } ); + + sess._send_header_and_data( ["ZFILE"], payload_array, "end_ack" ); + + delete sess._sent_ZDATA; + + return handler_setter_promise; + }; + + if (FORCE_ESCAPE_CTRL_CHARS) { + return this._ensure_receiver_escapes_ctrl_chars().then(doer_func); + } + + return doer_func(); + } + + _send_header_and_data( hdr_name_and_args, data_arr, frameend ) { + var bytes_hdr = this._create_header_bytes(hdr_name_and_args); + + var data_bytes = this._build_subpacket_bytes(data_arr, frameend); + + bytes_hdr[0].push.apply( bytes_hdr[0], data_bytes ); + + if (Zmodem.DEBUG) { + this._log_header( "SENDING HEADER", bytes_hdr[1] ); + console.debug( this.type, "-- HEADER PAYLOAD:", frameend, data_bytes.length ); + } + + this._sender( bytes_hdr[0] ); + + this._last_sent_header = bytes_hdr[1]; + } + + _build_subpacket_bytes( bytes_arr, frameend ) { + var subpacket = Zmodem.Subpacket.build(bytes_arr, frameend); + + return subpacket[this._subpacket_encode_func]( this._zencoder ); + } + + _build_and_send_subpacket( bytes_arr, frameend ) { + this._sender( this._build_subpacket_bytes(bytes_arr, frameend) ); + } + + _string_to_octets(string) { + if (!this._textencoder) { + this._textencoder = new Zmodem.Text.Encoder(); + } + + var uint8arr = this._textencoder.encode(string); + return Array.prototype.slice.call(uint8arr); + } + + /* + Potential future support for responding to ZRPOS: + send_file_offset(offset) { + } + */ + + /* + Sending logic works thus: + - ASSUME the receiver can overlap I/O (CANOVIO) + (so fail if !CANFDX || !CANOVIO) + - Sender opens the firehose … all ZCRCG (!end/!ack) + until the end, when we send a ZCRCE (end/!ack) + NB: try 8k/32k/64k chunk sizes? Looks like there’s + no need to change the packet otherwise. + */ + //TODO: Put this on a Transfer object similar to what Receive uses? + _send_interim_file_piece(bytes_obj) { + + //We don’t ask the receiver to confirm because there’s no need. + this._send_file_part(bytes_obj, "no_end_no_ack"); + + //This pattern will allow + //error-correction without buffering the entire stream in JS. + //For now the promise is always resolved, but in the future we + //can make it only resolve once we’ve gotten acknowledgement. + return Promise.resolve(); + } + + _ensure_we_are_sending() { + if (!this._sending_file) throw "Not sending a file currently!"; + } + + //This resolves once we receive ZEOF. + _end_file(bytes_obj) { + this._ensure_we_are_sending(); + + //Is the frame-end-ness of this last packet redundant + //with the ZEOF packet?? - No. It signals the receiver that + //the next thing to expect is a header, not a packet. + + //no-ack, following lrzsz’s example + this._send_file_part(bytes_obj, "end_no_ack"); + + var sess = this; + + //Register this before we send ZEOF in case of local round-trip. + //(Basically just for synchronous testing, but.) + var ret = new Promise( function(res) { + //console.log("UNSETTING SENDING FLAG"); + sess._sending_file = false; + sess._prepare_to_receive_ZRINIT(res); + } ); + + this._send_header( "ZEOF", this._file_offset ); + + this._file_offset = 0; + + return ret; + } + + //Called at the beginning of our session + //and also when we’re done sending a file. + _prepare_to_receive_ZRINIT(after_consume) { + this._next_header_handler = { + ZRINIT: function(hdr) { + this._consume_ZRINIT(hdr); + if (after_consume) after_consume(); + }, + }; + } + + /** + * Signal to the receiver that the ZMODEM session is wrapping up. + * + * @returns {Promise} Resolves when the receiver has responded to + * our signal that the session is over. + */ + close() { + var ok_to_close = (this._last_header_name === "ZRINIT") + if (!ok_to_close) { + ok_to_close = (this._last_header_name === "ZSKIP"); + } + if (!ok_to_close) { + ok_to_close = (this._last_sent_header.name === "ZSINIT") && (this._last_header_name === "ZACK"); + } + + if (!ok_to_close) { + throw( "Can’t close; last received header was “" + this._last_header_name + "”" ); + } + + var sess = this; + + var ret = new Promise( function(res, rej) { + sess._next_header_handler = { + ZFIN: function() { + sess._sender( OVER_AND_OUT ); + sess._sent_OO = true; + sess._on_session_end(); + res(); + }, + }; + } ); + + this._send_header("ZFIN"); + + return ret; + } + + _has_ended() { + return this.aborted() || !!this._sent_OO; + } + + _send_file_part(bytes_obj, final_packetend) { + if (!this._sent_ZDATA) { + this._send_header( "ZDATA", this._file_offset ); + this._sent_ZDATA = true; + } + + var obj_offset = 0; + + var bytes_count = bytes_obj.length; + + //We have to go through at least once in event of an + //empty buffer, e.g., an empty end_file. + while (true) { + var chunk_size = Math.min(obj_offset + MAX_CHUNK_LENGTH, bytes_count) - obj_offset; + + var at_end = (chunk_size + obj_offset) >= bytes_count; + + var chunk = bytes_obj.slice( obj_offset, obj_offset + chunk_size ); + if (!(chunk instanceof Array)) { + chunk = Array.prototype.slice.call(chunk); + } + + this._build_and_send_subpacket( + chunk, + at_end ? final_packetend : "no_end_no_ack" + ); + + this._file_offset += chunk_size; + obj_offset += chunk_size; + + if (obj_offset >= bytes_count) break; + } + } + + _consume_first() { + if (!this._parse_and_consume_header()) { + + //When the ZMODEM receive program starts, it immediately sends + //a ZRINIT header to initiate ZMODEM file transfers, or a + //ZCHALLENGE header to verify the sending program. The receive + //program resends its header at response time (default 10 second) + //intervals for a suitable period of time (40 seconds total) + //before falling back to YMODEM protocol. + if (this._input_buffer.join() === "67") { + throw "Receiver has fallen back to YMODEM."; + } + } + } + + _on_session_end() { + this._stop_keepalive(); + super._on_session_end(); + } +} + +Object.assign( + Zmodem.Session.Send.prototype, + { + type: "send", + } +); diff --git a/src/zsubpacket.js b/src/zsubpacket.js new file mode 100644 index 0000000..f77a527 --- /dev/null +++ b/src/zsubpacket.js @@ -0,0 +1,241 @@ +"use strict"; + +var Zmodem = module.exports; + +Object.assign( + Zmodem, + require("./zcrc"), + require("./zdle"), + require("./zmlib"), + require("./zerror") +); + +const + ZCRCE = 0x68, // 'h', 104, frame ends, header packet follows + ZCRCG = 0x69, // 'i', 105, frame continues nonstop + ZCRCQ = 0x6a, // 'j', 106, frame continues, ZACK expected + ZCRCW = 0x6b // 'k', 107, frame ends, ZACK expected +; + +var SUBPACKET_BUILDER; + +/** Class that represents a ZMODEM data subpacket. */ +Zmodem.Subpacket = class ZmodemSubpacket { + + /** + * Build a Subpacket subclass given a payload and frame end string. + * + * @param {Array} octets - The octet values to parse. + * Each array member should be an 8-bit unsigned integer (0-255). + * + * @param {string} frameend - One of: + * - `no_end_no_ack` + * - `end_no_ack` + * - `no_end_ack` (unused currently) + * - `end_ack` + * + * @returns {Subpacket} An instance of the appropriate Subpacket subclass. + */ + static build(octets, frameend) { + + //TODO: make this better + var Ctr = SUBPACKET_BUILDER[frameend]; + if (!Ctr) { + throw("No subpacket type “" + frameend + "” is defined! Try one of: " + Object.keys(SUBPACKET_BUILDER).join(", ")); + } + + return new Ctr(octets); + } + + /** + * Return the octet values array that represents the object + * encoded with a 16-bit CRC. + * + * @param {ZDLE} zencoder - A ZDLE instance to use for ZDLE encoding. + * + * @returns {number[]} An array of octet values suitable for sending + * as binary data. + */ + encode16(zencoder) { + return this._encode( zencoder, Zmodem.CRC.crc16 ); + } + + /** + * Return the octet values array that represents the object + * encoded with a 32-bit CRC. + * + * @param {ZDLE} zencoder - A ZDLE instance to use for ZDLE encoding. + * + * @returns {number[]} An array of octet values suitable for sending + * as binary data. + */ + encode32(zencoder) { + return this._encode( zencoder, Zmodem.CRC.crc32 ); + } + + /** + * Return the subpacket payload’s octet values. + * + * NOTE: For speed, this returns the actual data in the subpacket; + * if you mutate this return value, you alter the Subpacket object + * internals. This is OK if you won’t need the Subpacket anymore, but + * just be careful. + * + * @returns {number[]} The subpacket’s payload, represented as an + * array of octet values. **DO NOT ALTER THIS ARRAY** unless you + * no longer need the Subpacket. + */ + get_payload() { return this._payload } + + /** + * Parse out a Subpacket object from a given array of octet values, + * assuming a 16-bit CRC. + * + * An exception is thrown if the given bytes are definitively invalid + * as subpacket values with 16-bit CRC. + * + * @param {number[]} octets - The octet values to parse. + * Each array member should be an 8-bit unsigned integer (0-255). + * This object is mutated in the function. + * + * @returns {Subpacket|undefined} An instance of the appropriate Subpacket + * subclass, or undefined if not enough octet values are given + * to determine whether there is a valid subpacket here or not. + */ + static parse16(octets) { + return ZmodemSubpacket._parse(octets, 2); + } + + //parse32 test: + //[102, 105, 108, 101, 110, 97, 109, 101, 119, 105, 116, 104, 115, 112, 97, 99, 101, 115, 0, 49, 55, 49, 51, 49, 52, 50, 52, 51, 50, 49, 55, 50, 49, 48, 48, 54, 52, 52, 48, 49, 49, 55, 0, 43, 8, 63, 115, 23, 17] + + /** + * Same as parse16(), but assuming a 32-bit CRC. + * + * @param {number[]} octets - The octet values to parse. + * Each array member should be an 8-bit unsigned integer (0-255). + * This object is mutated in the function. + * + * @returns {Subpacket|undefined} An instance of the appropriate Subpacket + * subclass, or undefined if not enough octet values are given + * to determine whether there is a valid subpacket here or not. + */ + static parse32(octets) { + return ZmodemSubpacket._parse(octets, 4); + } + + /** + * Not used directly. + */ + constructor(payload) { + this._payload = payload; + } + + _encode(zencoder, crc_func) { + return zencoder.encode( this._payload.slice(0) ).concat( + [ Zmodem.ZMLIB.ZDLE, this._frameend_num ], + zencoder.encode( crc_func( this._payload.concat(this._frameend_num) ) ) + ); + } + + //Because of ZDLE encoding, we’ll never see any of the frame-end octets + //in a stream except as the ends of data payloads. + static _parse(bytes_arr, crc_len) { + + var end_at; + var creator; + + //These have to be written in decimal since they’re lookup keys. + var _frame_ends_lookup = { + 104: ZEndNoAckSubpacket, + 105: ZNoEndNoAckSubpacket, + 106: ZNoEndAckSubpacket, + 107: ZEndAckSubpacket, + }; + + var zdle_at = 0; + while (zdle_at < bytes_arr.length) { + zdle_at = bytes_arr.indexOf( Zmodem.ZMLIB.ZDLE, zdle_at ); + if (zdle_at === -1) return; + + var after_zdle = bytes_arr[ zdle_at + 1 ]; + creator = _frame_ends_lookup[ after_zdle ]; + if (creator) { + end_at = zdle_at + 1; + break; + } + + zdle_at++; + } + + if (!creator) return; + + var frameend_num = bytes_arr[end_at]; + + //sanity check + if (bytes_arr[end_at - 1] !== Zmodem.ZMLIB.ZDLE) { + throw( "Byte before frame end should be ZDLE, not " + bytes_arr[end_at - 1] ); + } + + var zdle_encoded_payload = bytes_arr.splice( 0, end_at - 1 ); + + var got_crc = Zmodem.ZDLE.splice( bytes_arr, 2, crc_len ); + if (!got_crc) { + //got payload but no CRC yet .. should be rare! + + //We have to put the ZDLE-encoded payload back before returning. + bytes_arr.unshift.apply(bytes_arr, zdle_encoded_payload); + + return; + } + + var payload = Zmodem.ZDLE.decode(zdle_encoded_payload); + + //We really shouldn’t need to do this, but just for good measure. + //I suppose it’s conceivable this may run over UDP or something? + Zmodem.CRC[ (crc_len === 2) ? "verify16" : "verify32" ]( + payload.concat( [frameend_num] ), + got_crc + ); + + return new creator(payload, got_crc); + } +} + +class ZEndSubpacketBase extends Zmodem.Subpacket { + frame_end() { return true } +} +class ZNoEndSubpacketBase extends Zmodem.Subpacket { + frame_end() { return false } +} + +//Used for end-of-file. +class ZEndNoAckSubpacket extends ZEndSubpacketBase { + ack_expected() { return false } +} +ZEndNoAckSubpacket.prototype._frameend_num = ZCRCE; + +//Used for ZFILE and ZSINIT payloads. +class ZEndAckSubpacket extends ZEndSubpacketBase { + ack_expected() { return true } +} +ZEndAckSubpacket.prototype._frameend_num = ZCRCW; + +//Used for ZDATA, prior to end-of-file. +class ZNoEndNoAckSubpacket extends ZNoEndSubpacketBase { + ack_expected() { return false } +} +ZNoEndNoAckSubpacket.prototype._frameend_num = ZCRCG; + +//only used if receiver can full-duplex +class ZNoEndAckSubpacket extends ZNoEndSubpacketBase { + ack_expected() { return true } +} +ZNoEndAckSubpacket.prototype._frameend_num = ZCRCQ; + +SUBPACKET_BUILDER = { + end_no_ack: ZEndNoAckSubpacket, + end_ack: ZEndAckSubpacket, + no_end_no_ack: ZNoEndNoAckSubpacket, + no_end_ack: ZNoEndAckSubpacket, +}; diff --git a/src/zvalidation.js b/src/zvalidation.js new file mode 100644 index 0000000..e8618e9 --- /dev/null +++ b/src/zvalidation.js @@ -0,0 +1,130 @@ +"use strict"; + +var Zmodem = module.exports; + +Object.assign( + Zmodem, + require("./zerror") +); + +const LOOKS_LIKE_ZMODEM_HEADER = /\*\x18[AC]|\*\*\x18B/; + +function _validate_number(key, value) { + if (value < 0) { + throw new Zmodem.Error("validation", "“" + key + "” (" + value + ") must be nonnegative."); + } + + if (value !== Math.floor(value)) { + throw new Zmodem.Error("validation", "“" + key + "” (" + value + ") must be an integer."); + } +} + +/** Validation logic for zmodem.js + * + * @exports Validation + */ +Zmodem.Validation = { + + /** + * Validates and normalizes a set of parameters for an offer to send. + * NOTE: This returns “mtime” as epoch seconds, not a Date. This is + * inconsistent with the get_details() method in Session, but it’s + * more useful for sending over the wire. + * + * @param {FileDetails} params - The file details. Some fairly trivial + * variances from the specification are allowed. + * + * @return {FileDetails} The parameters that should be sent. `mtime` + * will be a Date rather than a number. + */ + offer_parameters: function offer_parameters(params) { + if (!params.name) { + throw new Zmodem.Error("validation", "Need “name”!"); + } + + if (typeof params.name !== "string") { + throw new Zmodem.Error("validation", "“name” (" + params.name + ") must be a string!"); + } + + //So that we can override values as is useful + //without affecting the passed-in object. + params = Object.assign({}, params); + + if (LOOKS_LIKE_ZMODEM_HEADER.test(params.name)) { + console.warn("The filename " + JSON.stringify(name) + " contains characters that look like a ZMODEM header. This could corrupt the ZMODEM session; consider renaming it so that the filename doesn’t contain control characters."); + } + + if (params.serial !== null && params.serial !== undefined) { + throw new Zmodem.Error("validation", "“serial” is meaningless."); + } + + params.serial = null; + + ["size", "mode", "files_remaining", "bytes_remaining"].forEach( + function(k) { + var ok; + switch (typeof params[k]) { + case "object": + ok = (params[k] === null); + break; + case "undefined": + params[k] = null; + ok = true; + break; + case "number": + _validate_number(k, params[k]); + + ok = true; + break; + } + + if (!ok) { + throw new Zmodem.Error("validation", "“" + k + "” (" + params[k] + ") must be null, undefined, or a number."); + } + } + ); + + if (typeof params.mode === "number") { + params.mode |= 0x8000; + } + + if (params.files_remaining === 0) { + throw new Zmodem.Error("validation", "“files_remaining”, if given, must be positive."); + } + + var mtime_ok; + switch (typeof params.mtime) { + case "object": + mtime_ok = true; + + if (params.mtime instanceof Date) { + + var date_obj = params.mtime; + params.mtime = Math.floor( date_obj.getTime() / 1000 ); + if (params.mtime < 0) { + throw new Zmodem.Error("validation", "“mtime” (" + date_obj + ") must not be earlier than 1970."); + } + } + else if (params.mtime !== null) { + mtime_ok = false; + } + + break; + + case "undefined": + params.mtime = null; + mtime_ok = true; + break; + case "number": + _validate_number("mtime", params.mtime); + mtime_ok = true; + break; + } + + if (!mtime_ok) { + throw new Zmodem.Error("validation", "“mtime” (" + params.mtime + ") must be null, undefined, a Date, or a number."); + } + + return params; + }, +}; -- cgit v1.2.3