summaryrefslogtreecommitdiffstats
path: root/src/zsentry.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/zsentry.js')
-rw-r--r--src/zsentry.js394
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;
+ }
+}