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