diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index 1c60e1f0..bb72c40a 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -8,10 +8,11 @@ const Address = require('./ftn_address.js'); const strUtil = require('./string_util.js'); const Log = require('./logger.js').log; const ansiPrep = require('./ansi_prep.js'); +const Errors = require('./enig_error.js').Errors; const _ = require('lodash'); const assert = require('assert'); -const binary = require('binary'); +const { Parser } = require('binary-parser'); const fs = require('graceful-fs'); const async = require('async'); const iconv = require('iconv-lite'); @@ -23,7 +24,6 @@ const FTN_PACKET_HEADER_SIZE = 58; // fixed header size const FTN_PACKET_HEADER_TYPE = 2; const FTN_PACKET_MESSAGE_TYPE = 2; const FTN_PACKET_BAUD_TYPE_2_2 = 2; -const NULL_TERM_BUFFER = new Buffer( [ 0x00 ] ); // SAUCE magic header + version ("00") const FTN_MESSAGE_SAUCE_HEADER = new Buffer('SAUCE00'); @@ -173,108 +173,103 @@ function Packet(options) { this.parsePacketHeader = function(packetBuffer, cb) { assert(Buffer.isBuffer(packetBuffer)); - if(packetBuffer.length < FTN_PACKET_HEADER_SIZE) { - cb(new Error('Buffer too small')); - return; + let packetHeader; + try { + packetHeader = new Parser() + .uint16le('origNode') + .uint16le('destNode') + .uint16le('year') + .uint16le('month') + .uint16le('day') + .uint16le('hour') + .uint16le('minute') + .uint16le('second') + .uint16le('baud') + .uint16le('packetType') + .uint16le('origNet') + .uint16le('destNet') + .int8('prodCodeLo') + .int8('prodRevLo') // aka serialNo + .buffer('password', { length : 8 }) // can't use string; need CP437 - see https://github.com/keichi/binary-parser/issues/33 + .uint16le('origZone') + .uint16le('destZone') + // + // The following is "filler" in FTS-0001, specifics in + // FSC-0045 and FSC-0048 + // + .uint16le('auxNet') + .uint16le('capWordValidate') + .int8('prodCodeHi') + .int8('prodRevHi') + .uint16le('capWord') + .uint16le('origZone2') + .uint16le('destZone2') + .uint16le('origPoint') + .uint16le('destPoint') + .uint32le('prodData') + .parse(packetBuffer); + } catch(e) { + return Errors.Invalid(`Unable to parse FTN packet header: ${e.message}`); + } + + // Convert password from NULL padded array to string + packetHeader.password = strUtil.stringFromNullTermBuffer(packetHeader.password, 'CP437'); + + if(FTN_PACKET_HEADER_TYPE !== packetHeader.packetType) { + return cb(Errors.Invalid(`Unsupported FTN packet header type: ${packetHeader.packetType}`)); } // - // Start out reading as if this is a FSC-0048 2+ packet + // What kind of packet do we really have here? // - binary.parse(packetBuffer) - .word16lu('origNode') - .word16lu('destNode') - .word16lu('year') - .word16lu('month') - .word16lu('day') - .word16lu('hour') - .word16lu('minute') - .word16lu('second') - .word16lu('baud') - .word16lu('packetType') - .word16lu('origNet') - .word16lu('destNet') - .word8('prodCodeLo') - .word8('prodRevLo') // aka serialNo - .buffer('password', 8) // null padded C style string - .word16lu('origZone') - .word16lu('destZone') + // :TODO: adjust values based on version discovered + if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) { + packetHeader.version = '2.2'; + + // See FSC-0045 + packetHeader.origPoint = packetHeader.year; + packetHeader.destPoint = packetHeader.month; + + packetHeader.destDomain = packetHeader.origZone2; + packetHeader.origDomain = packetHeader.auxNet; + } else { // - // The following is "filler" in FTS-0001, specifics in - // FSC-0045 and FSC-0048 + // See heuristics described in FSC-0048, "Receiving Type-2+ bundles" // - .word16lu('auxNet') - .word16lu('capWordValidate') - .word8('prodCodeHi') - .word8('prodRevHi') - .word16lu('capWord') - .word16lu('origZone2') - .word16lu('destZone2') - .word16lu('origPoint') - .word16lu('destPoint') - .word32lu('prodData') - .tap(packetHeader => { - // Convert password from NULL padded array to string - //packetHeader.password = ftn.stringFromFTN(packetHeader.password); - packetHeader.password = strUtil.stringFromNullTermBuffer(packetHeader.password, 'CP437'); + const capWordValidateSwapped = + ((packetHeader.capWordValidate & 0xff) << 8) | + ((packetHeader.capWordValidate >> 8) & 0xff); - if(FTN_PACKET_HEADER_TYPE !== packetHeader.packetType) { - cb(new Error('Unsupported header type: ' + packetHeader.packetType)); - return; + if(capWordValidateSwapped === packetHeader.capWord && + 0 != packetHeader.capWord && + packetHeader.capWord & 0x0001) + { + packetHeader.version = '2+'; + + // See FSC-0048 + if(-1 === packetHeader.origNet) { + packetHeader.origNet = packetHeader.auxNet; } + } else { + packetHeader.version = '2'; - // - // What kind of packet do we really have here? - // - // :TODO: adjust values based on version discovered - if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) { - packetHeader.version = '2.2'; + // :TODO: should fill bytes be 0? + } + } - // See FSC-0045 - packetHeader.origPoint = packetHeader.year; - packetHeader.destPoint = packetHeader.month; + packetHeader.created = moment({ + year : packetHeader.year, + month : packetHeader.month - 1, // moment uses 0 indexed months + date : packetHeader.day, + hour : packetHeader.hour, + minute : packetHeader.minute, + second : packetHeader.second + }); - packetHeader.destDomain = packetHeader.origZone2; - packetHeader.origDomain = packetHeader.auxNet; - } else { - // - // See heuristics described in FSC-0048, "Receiving Type-2+ bundles" - // - const capWordValidateSwapped = - ((packetHeader.capWordValidate & 0xff) << 8) | - ((packetHeader.capWordValidate >> 8) & 0xff); + const ph = new PacketHeader(); + _.assign(ph, packetHeader); - if(capWordValidateSwapped === packetHeader.capWord && - 0 != packetHeader.capWord && - packetHeader.capWord & 0x0001) - { - packetHeader.version = '2+'; - - // See FSC-0048 - if(-1 === packetHeader.origNet) { - packetHeader.origNet = packetHeader.auxNet; - } - } else { - packetHeader.version = '2'; - - // :TODO: should fill bytes be 0? - } - } - - packetHeader.created = moment({ - year : packetHeader.year, - month : packetHeader.month - 1, // moment uses 0 indexed months - date : packetHeader.day, - hour : packetHeader.hour, - minute : packetHeader.minute, - second : packetHeader.second - }); - - let ph = new PacketHeader(); - _.assign(ph, packetHeader); - - cb(null, ph); - }); + return cb(null, ph); }; this.getPacketHeaderBuffer = function(packetHeader) { @@ -454,21 +449,30 @@ function Packet(options) { // :TODO: See encodingFromHeader() for CHRS/CHARSET support @ https://github.com/Mithgol/node-fidonet-jam const FTN_CHRS_PREFIX = new Buffer( [ 0x01, 0x43, 0x48, 0x52, 0x53, 0x3a, 0x20 ] ); // "\x01CHRS:" const FTN_CHRS_SUFFIX = new Buffer( [ 0x0d ] ); - binary.parse(messageBodyBuffer) - .scan('prefix', FTN_CHRS_PREFIX) - .scan('content', FTN_CHRS_SUFFIX) - .tap(chrsData => { - if(chrsData.prefix && chrsData.content && chrsData.content.length > 0) { - const chrs = iconv.decode(chrsData.content, 'CP437'); - const chrsEncoding = ftn.getEncodingFromCharacterSetIdentifier(chrs); - if(chrsEncoding) { - encoding = chrsEncoding; - } - callback(null); - } else { - callback(null); - } - }); + + let chrsPrefixIndex = messageBodyBuffer.indexOf(FTN_CHRS_PREFIX); + if(chrsPrefixIndex < 0) { + return callback(null); + } + + chrsPrefixIndex += FTN_CHRS_PREFIX.length; + + const chrsEndIndex = messageBodyBuffer.indexOf(FTN_CHRS_SUFFIX, chrsPrefixIndex); + if(chrsEndIndex < 0) { + return callback(null); + } + + let chrsContent = messageBodyBuffer.slice(chrsPrefixIndex, chrsEndIndex); + if(0 === chrsContent.length) { + return callback(null); + } + + chrsContent = iconv.decode(chrsContent, 'CP437'); + const chrsEncoding = ftn.getEncodingFromCharacterSetIdentifier(chrsContent); + if(chrsEncoding) { + encoding = chrsEncoding; + } + return callback(null); }, function extractMessageData(callback) { // @@ -525,125 +529,160 @@ function Packet(options) { }; this.parsePacketMessages = function(header, packetBuffer, iterator, cb) { - binary.parse(packetBuffer) - .word16lu('messageType') - .word16lu('ftn_msg_orig_node') - .word16lu('ftn_msg_dest_node') - .word16lu('ftn_msg_orig_net') - .word16lu('ftn_msg_dest_net') - .word16lu('ftn_attr_flags') - .word16lu('ftn_cost') - .scan('modDateTime', NULL_TERM_BUFFER) // :TODO: 20 bytes max - .scan('toUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max - .scan('fromUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max - .scan('subject', NULL_TERM_BUFFER) // :TODO: 72 bytes max6 - .scan('message', NULL_TERM_BUFFER) - .tap(function tapped(msgData) { // no arrow function; want classic this - if(!msgData.messageType) { - // end marker -- no more messages - return cb(null); + // + // Check for end-of-messages marker up front before parse so we can easily + // tell the difference between end and bad header + // + if(packetBuffer.length < 3) { + const peek = packetBuffer.slice(0, 2); + if(peek.equals(Buffer.from([ 0x00 ])) || peek.equals(Buffer.from( [ 0x00, 0x00 ]))) { + // end marker - no more messages + return cb(null); + } + // else fall through & hit exception below to log error + } + + let msgData; + try { + msgData = new Parser() + .uint16le('messageType') + .uint16le('ftn_msg_orig_node') + .uint16le('ftn_msg_dest_node') + .uint16le('ftn_msg_orig_net') + .uint16le('ftn_msg_dest_net') + .uint16le('ftn_attr_flags') + .uint16le('ftn_cost') + // :TODO: use string() for these if https://github.com/keichi/binary-parser/issues/33 is resolved + .array('modDateTime', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .array('toUserName', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .array('fromUserName', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .array('subject', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .array('message', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .parse(packetBuffer); + } catch(e) { + return cb(Errors.Invalid(`Failed to parse FTN message header: ${e.message}`)); + } + + if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) { + return cb(Errors.Invalid(`Unsupported FTN message type: ${msgData.messageType}`)); + } + + // + // Convert null terminated arrays to strings + // + [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => { + msgData[k] = strUtil.stringFromNullTermBuffer(msgData[k], 'CP437'); + }); + + // Technically the following fields have length limits as per fts-0001.016: + // * modDateTime : 20 bytes + // * toUserName : 36 bytes + // * fromUserName : 36 bytes + // * subject : 72 bytes + + // + // The message body itself is a special beast as it may + // contain an origin line, kludges, SAUCE in the case + // of ANSI files, etc. + // + const msg = new Message( { + toUserName : msgData.toUserName, + fromUserName : msgData.fromUserName, + subject : msgData.subject, + modTimestamp : ftn.getDateFromFtnDateTime(msgData.modDateTime), + }); + + // :TODO: When non-private (e.g. EchoMail), attempt to extract SRC from MSGID vs headers, when avail (or Orgin line? research further) + msg.meta.FtnProperty = { + ftn_orig_node : header.origNode, + ftn_dest_node : header.destNode, + ftn_orig_network : header.origNet, + ftn_dest_network : header.destNet, + + ftn_attr_flags : msgData.ftn_attr_flags, + ftn_cost : msgData.ftn_cost, + + ftn_msg_orig_node : msgData.ftn_msg_orig_node, + ftn_msg_dest_node : msgData.ftn_msg_dest_node, + ftn_msg_orig_net : msgData.ftn_msg_orig_net, + ftn_msg_dest_net : msgData.ftn_msg_dest_net, + }; + + self.processMessageBody(msgData.message, messageBodyData => { + msg.message = messageBodyData.message; + msg.meta.FtnKludge = messageBodyData.kludgeLines; + + if(messageBodyData.tearLine) { + msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine; + + if(self.options.keepTearAndOrigin) { + msg.message += `\r\n${messageBodyData.tearLine}\r\n`; } + } - if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) { - return cb(new Error('Unsupported message type: ' + msgData.messageType)); + if(messageBodyData.seenBy.length > 0) { + msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy; + } + + if(messageBodyData.area) { + msg.meta.FtnProperty.ftn_area = messageBodyData.area; + } + + if(messageBodyData.originLine) { + msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine; + + if(self.options.keepTearAndOrigin) { + msg.message += `${messageBodyData.originLine}\r\n`; } + } - const read = - 14 + // fixed header size - msgData.modDateTime.length + 1 + - msgData.toUserName.length + 1 + - msgData.fromUserName.length + 1 + - msgData.subject.length + 1 + - msgData.message.length + 1; + // + // If we have a UTC offset kludge (e.g. TZUTC) then update + // modDateTime with it + // + if(_.isString(msg.meta.FtnKludge.TZUTC) && msg.meta.FtnKludge.TZUTC.length > 0) { + msg.modDateTime = msg.modTimestamp.utcOffset(msg.meta.FtnKludge.TZUTC); + } - // - // Convert null terminated arrays to strings - // - let convMsgData = {}; - [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => { - convMsgData[k] = iconv.decode(msgData[k], 'CP437'); - }); + // :TODO: Parser should give is this info: + const bytesRead = + 14 + // fixed header size + msgData.modDateTime.length + 1 + // +1 = NULL + msgData.toUserName.length + 1 + // +1 = NULL + msgData.fromUserName.length + 1 + // +1 = NULL + msgData.subject.length + 1 + // +1 = NULL + msgData.message.length; // includes NULL - // - // The message body itself is a special beast as it may - // contain an origin line, kludges, SAUCE in the case - // of ANSI files, etc. - // - const msg = new Message( { - toUserName : convMsgData.toUserName, - fromUserName : convMsgData.fromUserName, - subject : convMsgData.subject, - modTimestamp : ftn.getDateFromFtnDateTime(convMsgData.modDateTime), - }); - - // :TODO: When non-private (e.g. EchoMail), attempt to extract SRC from MSGID vs headers, when avail (or Orgin line? research further) - msg.meta.FtnProperty = { - ftn_orig_node : header.origNode, - ftn_dest_node : header.destNode, - ftn_orig_network : header.origNet, - ftn_dest_network : header.destNet, - - ftn_attr_flags : msgData.ftn_attr_flags, - ftn_cost : msgData.ftn_cost, - - ftn_msg_orig_node : msgData.ftn_msg_orig_node, - ftn_msg_dest_node : msgData.ftn_msg_dest_node, - ftn_msg_orig_net : msgData.ftn_msg_orig_net, - ftn_msg_dest_net : msgData.ftn_msg_dest_net, + const nextBuf = packetBuffer.slice(bytesRead); + if(nextBuf.length > 0) { + const next = function(e) { + if(e) { + cb(e); + } else { + self.parsePacketMessages(header, nextBuf, iterator, cb); + } }; - self.processMessageBody(msgData.message, messageBodyData => { - msg.message = messageBodyData.message; - msg.meta.FtnKludge = messageBodyData.kludgeLines; - - if(messageBodyData.tearLine) { - msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine; - - if(self.options.keepTearAndOrigin) { - msg.message += `\r\n${messageBodyData.tearLine}\r\n`; - } - } - - if(messageBodyData.seenBy.length > 0) { - msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy; - } - - if(messageBodyData.area) { - msg.meta.FtnProperty.ftn_area = messageBodyData.area; - } - - if(messageBodyData.originLine) { - msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine; - - if(self.options.keepTearAndOrigin) { - msg.message += `${messageBodyData.originLine}\r\n`; - } - } - - // - // If we have a UTC offset kludge (e.g. TZUTC) then update - // modDateTime with it - // - if(_.isString(msg.meta.FtnKludge.TZUTC) && msg.meta.FtnKludge.TZUTC.length > 0) { - msg.modDateTime = msg.modTimestamp.utcOffset(msg.meta.FtnKludge.TZUTC); - } - - const nextBuf = packetBuffer.slice(read); - if(nextBuf.length > 0) { - const next = function(e) { - if(e) { - cb(e); - } else { - self.parsePacketMessages(header, nextBuf, iterator, cb); - } - }; - - iterator('message', msg, next); - } else { - cb(null); - } - }); - }); + iterator('message', msg, next); + } else { + cb(null); + } + }); }; this.sanatizeFtnProperties = function(message) { diff --git a/core/sauce.js b/core/sauce.js index b976450b..9ee75b47 100644 --- a/core/sauce.js +++ b/core/sauce.js @@ -1,8 +1,11 @@ /* jslint node: true */ 'use strict'; -var binary = require('binary'); -var iconv = require('iconv-lite'); +const Errors = require('./enig_error.js').Errors; + +// deps +const iconv = require('iconv-lite'); +const { Parser } = require('binary-parser'); exports.readSAUCE = readSAUCE; @@ -25,103 +28,107 @@ const SAUCE_VALID_DATA_TYPES = [0, 1, 2, 3, 4, 5, 6, 7, 8 ]; function readSAUCE(data, cb) { if(data.length < SAUCE_SIZE) { - cb(new Error('No SAUCE record present')); - return; + return cb(Errors.DoesNotExist('No SAUCE record present')); } - var offset = data.length - SAUCE_SIZE; - var sauceRec = data.slice(offset); + let sauceRec; + try { + sauceRec = new Parser() + .buffer('id', { length : 5 } ) + .buffer('version', { length : 2 } ) + .buffer('title', { length: 35 } ) + .buffer('author', { length : 20 } ) + .buffer('group', { length: 20 } ) + .buffer('date', { length: 8 } ) + .uint32le('fileSize') + .int8('dataType') + .int8('fileType') + .uint16le('tinfo1') + .uint16le('tinfo2') + .uint16le('tinfo3') + .uint16le('tinfo4') + .int8('numComments') + .int8('flags') + // :TODO: does this need to be optional? + .buffer('tinfos', { length: 22 } ) // SAUCE 00.5 + .parse(data.slice(data.length - SAUCE_SIZE)); + } catch(e) { + return cb(Errors.Invalid('Invalid SAUCE record')); + } - binary.parse(sauceRec) - .buffer('id', 5) - .buffer('version', 2) - .buffer('title', 35) - .buffer('author', 20) - .buffer('group', 20) - .buffer('date', 8) - .word32lu('fileSize') - .word8('dataType') - .word8('fileType') - .word16lu('tinfo1') - .word16lu('tinfo2') - .word16lu('tinfo3') - .word16lu('tinfo4') - .word8('numComments') - .word8('flags') - .buffer('tinfos', 22) // SAUCE 00.5 - .tap(function onVars(vars) { - if(!SAUCE_ID.equals(vars.id)) { - return cb(new Error('No SAUCE record present')); - } + if(!SAUCE_ID.equals(sauceRec.id)) { + return cb(Errors.DoesNotExist('No SAUCE record present')); + } - var ver = iconv.decode(vars.version, 'cp437'); + const ver = iconv.decode(sauceRec.version, 'cp437'); - if('00' !== ver) { - return cb(new Error('Unsupported SAUCE version: ' + ver)); - } + if('00' !== ver) { + return cb(Errors.Invalid(`Unsupported SAUCE version: ${ver}`)); + } - if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(vars.dataType)) { - return cb(new Error('Unsupported SAUCE DataType: ' + vars.dataType)); - } + if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(sauceRec.dataType)) { + return cb(Errors.Invalid(`Unsupported SAUCE DataType: ${sauceRec.dataType}`)); + } - var sauce = { - id : iconv.decode(vars.id, 'cp437'), - version : iconv.decode(vars.version, 'cp437').trim(), - title : iconv.decode(vars.title, 'cp437').trim(), - author : iconv.decode(vars.author, 'cp437').trim(), - group : iconv.decode(vars.group, 'cp437').trim(), - date : iconv.decode(vars.date, 'cp437').trim(), - fileSize : vars.fileSize, - dataType : vars.dataType, - fileType : vars.fileType, - tinfo1 : vars.tinfo1, - tinfo2 : vars.tinfo2, - tinfo3 : vars.tinfo3, - tinfo4 : vars.tinfo4, - numComments : vars.numComments, - flags : vars.flags, - tinfos : vars.tinfos, - }; + const sauce = { + id : iconv.decode(sauceRec.id, 'cp437'), + version : iconv.decode(sauceRec.version, 'cp437').trim(), + title : iconv.decode(sauceRec.title, 'cp437').trim(), + author : iconv.decode(sauceRec.author, 'cp437').trim(), + group : iconv.decode(sauceRec.group, 'cp437').trim(), + date : iconv.decode(sauceRec.date, 'cp437').trim(), + fileSize : sauceRec.fileSize, + dataType : sauceRec.dataType, + fileType : sauceRec.fileType, + tinfo1 : sauceRec.tinfo1, + tinfo2 : sauceRec.tinfo2, + tinfo3 : sauceRec.tinfo3, + tinfo4 : sauceRec.tinfo4, + numComments : sauceRec.numComments, + flags : sauceRec.flags, + tinfos : sauceRec.tinfos, + }; - var dt = SAUCE_DATA_TYPES[sauce.dataType]; - if(dt && dt.parser) { - sauce[dt.name] = dt.parser(sauce); - } + const dt = SAUCE_DATA_TYPES[sauce.dataType]; + if(dt && dt.parser) { + sauce[dt.name] = dt.parser(sauce); + } - cb(null, sauce); - }); + return cb(null, sauce); } // :TODO: These need completed: -var SAUCE_DATA_TYPES = {}; -SAUCE_DATA_TYPES[0] = { name : 'None' }; -SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; -SAUCE_DATA_TYPES[2] = 'Bitmap'; -SAUCE_DATA_TYPES[3] = 'Vector'; -SAUCE_DATA_TYPES[4] = 'Audio'; -SAUCE_DATA_TYPES[5] = 'BinaryText'; -SAUCE_DATA_TYPES[6] = 'XBin'; -SAUCE_DATA_TYPES[7] = 'Archive'; -SAUCE_DATA_TYPES[8] = 'Executable'; +const SAUCE_DATA_TYPES = { + 0 : { name : 'None' }, + 1 : { name : 'Character', parser : parseCharacterSAUCE }, + 2 : 'Bitmap', + 3 : 'Vector', + 4 : 'Audio', + 5 : 'BinaryText', + 6 : 'XBin', + 7 : 'Archive', + 8 : 'Executable', +}; -var SAUCE_CHARACTER_FILE_TYPES = {}; -SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; -SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; -SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; -SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; -SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; -SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; -SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; -SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; -SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; +const SAUCE_CHARACTER_FILE_TYPES = { + 0 : 'ASCII', + 1 : 'ANSi', + 2 : 'ANSiMation', + 3 : 'RIP script', + 4 : 'PCBoard', + 5 : 'Avatar', + 6 : 'HTML', + 7 : 'Source', + 8 : 'TundraDraw', +}; // // Map of SAUCE font -> encoding hint // // Note that this is the same mapping that x84 uses. Be compatible! // -var SAUCE_FONT_TO_ENCODING_HINT = { +const SAUCE_FONT_TO_ENCODING_HINT = { 'Amiga MicroKnight' : 'amiga', 'Amiga MicroKnight+' : 'amiga', 'Amiga mOsOul' : 'amiga', @@ -138,9 +145,11 @@ var SAUCE_FONT_TO_ENCODING_HINT = { 'IBM VGA' : 'cp437', }; -['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', - '860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { - var codec = 'cp' + page; +[ + '437', '720', '737', '775', '819', '850', '852', '855', '857', '858', + '860', '861', '862', '863', '864', '865', '866', '869', '872' +].forEach( page => { + const codec = 'cp' + page; SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; @@ -149,7 +158,7 @@ var SAUCE_FONT_TO_ENCODING_HINT = { }); function parseCharacterSAUCE(sauce) { - var result = {}; + const result = {}; result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; @@ -157,11 +166,12 @@ function parseCharacterSAUCE(sauce) { // convience: create ansiFlags sauce.ansiFlags = sauce.flags; - var i = 0; + let i = 0; while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { ++i; } - var fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); + + const fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); if(fontName.length > 0) { result.fontName = fontName; } diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index a6fa0deb..6ffb49a9 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -2,16 +2,17 @@ 'use strict'; // ENiGMA½ -const baseClient = require('../../client.js'); -const Log = require('../../logger.js').log; -const LoginServerModule = require('../../login_server_module.js'); -const Config = require('../../config.js').config; -const EnigAssert = require('../../enigma_assert.js'); +const baseClient = require('../../client.js'); +const Log = require('../../logger.js').log; +const LoginServerModule = require('../../login_server_module.js'); +const Config = require('../../config.js').config; +const EnigAssert = require('../../enigma_assert.js'); +const { stringFromNullTermBuffer } = require('../../string_util.js'); // deps const net = require('net'); const buffers = require('buffers'); -const binary = require('binary'); +const { Parser } = require('binary-parser'); const util = require('util'); //var debug = require('debug')('telnet'); @@ -218,46 +219,42 @@ OPTION_IMPLS[OPTIONS.TERMINAL_TYPE] = function(bufs, i, event) { return MORE_DATA_REQUIRED; } - let end = bufs.indexOf(IAC_SE_BUF, 5); // look past header bytes + const end = bufs.indexOf(IAC_SE_BUF, 5); // look past header bytes if(-1 === end) { return MORE_DATA_REQUIRED; } - // eat up and process the header - let buf = bufs.splice(0, 4).toBuffer(); - binary.parse(buf) - .word8('iac1') - .word8('sb') - .word8('ttype') - .word8('is') - .tap(function(vars) { - EnigAssert(vars.iac1 === COMMANDS.IAC); - EnigAssert(vars.sb === COMMANDS.SB); - EnigAssert(vars.ttype === OPTIONS.TERMINAL_TYPE); - EnigAssert(vars.is === SB_COMMANDS.IS); - }); - - // eat up the rest - end -= 4; - buf = bufs.splice(0, end).toBuffer(); - - // - // From this point -> |end| is our ttype - // - // Look for trailing NULL(s). Clients such as NetRunner do this. - // If none is found, we take the entire buffer - // - let trimAt = 0; - for(; trimAt < buf.length; ++trimAt) { - if(0x00 === buf[trimAt]) { - break; - } + let ttypeCmd; + try { + ttypeCmd = new Parser() + .uint8('iac1') + .uint8('sb') + .uint8('opt') + .uint8('is') + .array('ttype', { + type : 'uint8', + readUntil : b => 255 === b, // 255=COMMANDS.IAC + }) + // note we read iac2 above + .uint8('se') + .parse(bufs.toBuffer()); + } catch(e) { + Log.debug( { error : e }, 'Failed parsing TTYP telnet command'); + return event; } - event.ttype = buf.toString('ascii', 0, trimAt); + EnigAssert(COMMANDS.IAC === ttypeCmd.iac1); + EnigAssert(COMMANDS.SB === ttypeCmd.sb); + EnigAssert(OPTIONS.TERMINAL_TYPE === ttypeCmd.opt); + EnigAssert(SB_COMMANDS.IS === ttypeCmd.is); + EnigAssert(ttypeCmd.ttype.length > 0); + // note we found IAC_SE above - // pop off the terminating IAC SE - bufs.splice(0, 2); + // some terminals such as NetRunner provide a NULL-terminated buffer + // slice to remove IAC + event.ttype = stringFromNullTermBuffer(ttypeCmd.ttype.slice(0, -1), 'ascii'); + + bufs.splice(0, end); } return event; @@ -272,25 +269,30 @@ OPTION_IMPLS[OPTIONS.WINDOW_SIZE] = function(bufs, i, event) { return MORE_DATA_REQUIRED; } - event.buf = bufs.splice(0, 9).toBuffer(); - binary.parse(event.buf) - .word8('iac1') - .word8('sb') - .word8('naws') - .word16bu('width') - .word16bu('height') - .word8('iac2') - .word8('se') - .tap(function(vars) { - EnigAssert(vars.iac1 == COMMANDS.IAC); - EnigAssert(vars.sb == COMMANDS.SB); - EnigAssert(vars.naws == OPTIONS.WINDOW_SIZE); - EnigAssert(vars.iac2 == COMMANDS.IAC); - EnigAssert(vars.se == COMMANDS.SE); + let nawsCmd; + try { + nawsCmd = new Parser() + .uint8('iac1') + .uint8('sb') + .uint8('opt') + .uint16be('width') + .uint16be('height') + .uint8('iac2') + .uint8('se') + .parse(bufs.splice(0, 9).toBuffer()); + } catch(e) { + Log.debug( { error : e }, 'Failed parsing NAWS telnet command'); + return event; + } - event.cols = event.columns = event.width = vars.width; - event.rows = event.height = vars.height; - }); + EnigAssert(COMMANDS.IAC === nawsCmd.iac1); + EnigAssert(COMMANDS.SB === nawsCmd.sb); + EnigAssert(OPTIONS.WINDOW_SIZE === nawsCmd.opt); + EnigAssert(COMMANDS.IAC === nawsCmd.iac2); + EnigAssert(COMMANDS.SE === nawsCmd.se); + + event.cols = event.columns = event.width = nawsCmd.width; + event.rows = event.height = nawsCmd.height; } return event; }; @@ -321,78 +323,109 @@ OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT] = function(bufs, i, event) { return MORE_DATA_REQUIRED; } - // eat up and process the header - let buf = bufs.splice(0, 4).toBuffer(); - binary.parse(buf) - .word8('iac1') - .word8('sb') - .word8('newEnv') - .word8('isOrInfo') // initial=IS, updates=INFO - .tap(function(vars) { - EnigAssert(vars.iac1 === COMMANDS.IAC); - EnigAssert(vars.sb === COMMANDS.SB); - EnigAssert(vars.newEnv === OPTIONS.NEW_ENVIRONMENT || vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP); - EnigAssert(vars.isOrInfo === SB_COMMANDS.IS || vars.isOrInfo === SB_COMMANDS.INFO); + // :TODO: It's likely that we could do all the env name/value parsing directly in Parser. - event.type = vars.isOrInfo; - - if(vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP) { - // :TODO: bring all this into Telnet class - Log.log.warn('Handling deprecated RFC 1408 NEW-ENVIRON'); - } - }); - - // eat up the rest - end -= 4; - buf = bufs.splice(0, end).toBuffer(); - - // - // This part can become messy. The basic spec is: - // IAC SB NEW-ENVIRON IS type ... [ VALUE ... ] [ type ... [ VALUE ... ] [ ... ] ] IAC SE - // - // See RFC 1572 @ http://www.faqs.org/rfcs/rfc1572.html - // - // Start by splitting up the remaining buffer. Keep the delimiters - // as prefixes we can use for processing. - // - // :TODO: Currently not supporting ESCaped values (ESC + ). Probably not really in the wild, but we should be compliant - // :TODO: Could probably just convert this to use a regex & handle delims + escaped values... in any case, this is sloppy... - const params = []; - let p = 0; - let j; - let l; - for(j = 0, l = buf.length; j < l; ++j) { - if(NEW_ENVIRONMENT_DELIMITERS.indexOf(buf[j]) === -1) { - continue; - } - - params.push(buf.slice(p, j)); - p = j; + let envCmd; + try { + envCmd = new Parser() + .uint8('iac1') + .uint8('sb') + .uint8('opt') + .uint8('isOrInfo') // IS=initial, INFO=updates + .array('envBlock', { + type : 'uint8', + readUntil : b => 255 === b, // 255=COMMANDS.IAC + }) + // note we consume IAC above + .uint8('se') + .parse(bufs.splice(0, bufs.length).toBuffer()); + } catch(e) { + Log.debug( { error : e }, 'Failed parsing NEW-ENVIRON telnet command'); + return event; } - // remainder - if(p < l) { - params.push(buf.slice(p, l)); + EnigAssert(COMMANDS.IAC === envCmd.iac1); + EnigAssert(COMMANDS.SB === envCmd.sb); + EnigAssert(OPTIONS.NEW_ENVIRONMENT === envCmd.opt || OPTIONS.NEW_ENVIRONMENT_DEP === envCmd.opt); + EnigAssert(SB_COMMANDS.IS === envCmd.isOrInfo || SB_COMMANDS.INFO === envCmd.isOrInfo); + + if(OPTIONS.NEW_ENVIRONMENT_DEP === envCmd.opt) { + // :TODO: we should probably support this for legacy clients? + Log.warn('Handling deprecated RFC 1408 NEW-ENVIRON'); } + const envBuf = envCmd.envBlock.slice(0, -1); // remove IAC + + if(envBuf.length < 4) { // TYPE + single char name + sep + single char value + // empty env block + return event; + } + + const States = { + Name : 1, + Value : 2, + }; + + let state = States.Name; + const setVars = {}; + const delVars = []; let varName; - event.envVars = {}; - // :TODO: handle cases where a variable was present in a previous exchange, but missing here...e.g removed - for(j = 0; j < params.length; ++j) { - if(params[j].length < 2) { - continue; - } + // :TODO: handle ESC type!!! + while(envBuf.length) { + switch(state) { + case States.Name : + { + const type = parseInt(envBuf.splice(0, 1)); + if(![ NEW_ENVIRONMENT_COMMANDS.VAR, NEW_ENVIRONMENT_COMMANDS.USERVAR, NEW_ENVIRONMENT_COMMANDS.ESC ].includes(type)) { + return event; // fail :( + } - let cmd = params[j].readUInt8(); - if(cmd === NEW_ENVIRONMENT_COMMANDS.VAR || cmd === NEW_ENVIRONMENT_COMMANDS.USERVAR) { - varName = params[j].slice(1).toString('utf8'); // :TODO: what encoding should this really be? - } else { - event.envVars[varName] = params[j].slice(1).toString('utf8'); // :TODO: again, what encoding? + let nameEnd = envBuf.indexOf(NEW_ENVIRONMENT_COMMANDS.VALUE); + if(-1 === nameEnd) { + nameEnd = envBuf.length; + } + + varName = envBuf.splice(0, nameEnd); + if(!varName) { + return event; // something is wrong. + } + + varName = Buffer.from(varName).toString('ascii'); + + const next = parseInt(envBuf.splice(0, 1)); + if(NEW_ENVIRONMENT_COMMANDS.VALUE === next) { + state = States.Value; + } else { + state = States.Name; + delVars.push(varName); // no value; del this var + } + } + break; + + case States.Value : + { + let valueEnd = envBuf.indexOf(NEW_ENVIRONMENT_COMMANDS.VAR); + if(-1 === valueEnd) { + valueEnd = envBuf.indexOf(NEW_ENVIRONMENT_COMMANDS.USERVAR); + } + if(-1 === valueEnd) { + valueEnd = envBuf.length; + } + + let value = envBuf.splice(0, valueEnd); + if(value) { + value = Buffer.from(value).toString('ascii'); + setVars[varName] = value; + } + state = States.Name; + } + break; } } - // pop off remaining IAC SE - bufs.splice(0, 2); + // :TODO: Handle deleting previously set vars via delVars + event.type = envCmd.isOrInfo; + event.envVars = setVars; } return event; diff --git a/core/string_util.js b/core/string_util.js index c846fc38..f47bd6b5 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -204,7 +204,7 @@ function debugEscapedString(s) { } function stringFromNullTermBuffer(buf, encoding) { - let nullPos = buf.indexOf(new Buffer( [ 0x00 ] )); + let nullPos = buf.indexOf( 0x00 ); if(-1 === nullPos) { nullPos = buf.length; } diff --git a/package.json b/package.json index b44cc894..8c7a7a5d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "dependencies": { "async": "^2.5.0", - "binary": "0.3.x", + "binary-parser": "^1.3.2", "buffers": "NuSkooler/node-buffers", "bunyan": "^1.8.12", "exiftool": "^0.0.3",