diff options
Diffstat (limited to 'src/zsession.js')
-rw-r--r-- | src/zsession.js | 1677 |
1 files changed, 1677 insertions, 0 deletions
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", + } +); |