1// Copyright 2014 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5'use strict'; 6 7/** @suppress {duplicate} */ 8var remoting = remoting || {}; 9 10/** 11 * XmppStreamParser is used to parse XMPP stream. Data is fed to the parser 12 * using appendData() method and it calls |onStanzaCallback| and 13 * |onErrorCallback| specified using setCallbacks(). 14 * 15 * @constructor 16 */ 17remoting.XmppStreamParser = function() { 18 /** @type {function(Element):void} @private */ 19 this.onStanzaCallback_ = function(stanza) {}; 20 /** @type {function(string):void} @private */ 21 this.onErrorCallback_ = function(error) {}; 22 23 /** 24 * Buffer containing the data that has been received but haven't been parsed. 25 * @private 26 */ 27 this.data_ = new ArrayBuffer(0); 28 29 /** 30 * Current depth in the XML stream. 31 * @private 32 */ 33 this.depth_ = 0; 34 35 /** 36 * Set to true after error. 37 * @private 38 */ 39 this.error_ = false; 40 41 /** 42 * The <stream> opening tag received at the beginning of the stream. 43 * @private 44 */ 45 this.startTag_ = ''; 46 47 /** 48 * Closing tag matching |startTag_|. 49 * @private 50 */ 51 this.startTagEnd_ = ''; 52 53 /** 54 * String containing current incomplete stanza. 55 * @private 56 */ 57 this.currentStanza_ = ''; 58} 59 60/** 61 * Sets callbacks to be called on incoming stanzas and on error. 62 * 63 * @param {function(Element):void} onStanzaCallback 64 * @param {function(string):void} onErrorCallback 65 */ 66remoting.XmppStreamParser.prototype.setCallbacks = 67 function(onStanzaCallback, onErrorCallback) { 68 this.onStanzaCallback_ = onStanzaCallback; 69 this.onErrorCallback_ = onErrorCallback; 70} 71 72/** @param {ArrayBuffer} data */ 73remoting.XmppStreamParser.prototype.appendData = function(data) { 74 base.debug.assert(!this.error_); 75 76 if (this.data_.byteLength > 0) { 77 // Concatenate two buffers. 78 var newData = new Uint8Array(this.data_.byteLength + data.byteLength); 79 newData.set(new Uint8Array(this.data_), 0); 80 newData.set(new Uint8Array(data), this.data_.byteLength); 81 this.data_ = newData.buffer; 82 } else { 83 this.data_ = data; 84 } 85 86 // Check if the newly appended data completes XML tag or a piece of text by 87 // looking for '<' and '>' char codes. This has to be done before converting 88 // data to string because the input may not contain complete UTF-8 sequence. 89 var tagStartCode = '<'.charCodeAt(0); 90 var tagEndCode = '>'.charCodeAt(0); 91 var spaceCode = ' '.charCodeAt(0); 92 var tryAgain = true; 93 while (this.data_.byteLength > 0 && tryAgain && !this.error_) { 94 tryAgain = false; 95 96 // If we are not currently in a middle of a stanza then skip spaces (server 97 // may send spaces periodically as heartbeats) and make sure that the first 98 // character starts XML tag. 99 if (this.depth_ <= 1) { 100 var view = new DataView(this.data_); 101 var firstChar = view.getUint8(0); 102 if (firstChar == spaceCode) { 103 tryAgain = true; 104 this.data_ = this.data_.slice(1); 105 continue; 106 } else if (firstChar != tagStartCode) { 107 var dataAsText = ''; 108 try { 109 dataAsText = base.decodeUtf8(this.data_); 110 } catch (exception) { 111 dataAsText = 'charCode = ' + firstChar; 112 } 113 this.processError_('Received unexpected text data: ' + dataAsText); 114 return; 115 } 116 } 117 118 // Iterate over characters in the buffer to find complete tags. 119 var view = new DataView(this.data_); 120 for (var i = 0; i < view.byteLength; ++i) { 121 var currentChar = view.getUint8(i); 122 if (currentChar == tagStartCode) { 123 if (i > 0) { 124 var text = this.extractStringFromBuffer_(i); 125 if (text == null) 126 return; 127 this.processText_(text); 128 tryAgain = true; 129 break; 130 } 131 } else if (currentChar == tagEndCode) { 132 var tag = this.extractStringFromBuffer_(i + 1); 133 if (tag == null) 134 return; 135 if (tag.charAt(0) != '<') { 136 this.processError_('Received \'>\' without \'<\': ' + tag); 137 return; 138 } 139 this.processTag_(tag); 140 tryAgain = true; 141 break; 142 } 143 } 144 } 145} 146 147/** 148 * @param {string} text 149 * @private 150 */ 151remoting.XmppStreamParser.prototype.processText_ = function(text) { 152 // Tokenization code in appendData() shouldn't allow text tokens in between 153 // stanzas. 154 base.debug.assert(this.depth_ > 1); 155 this.currentStanza_ += text; 156} 157 158/** 159 * @param {string} tag 160 * @private 161 */ 162remoting.XmppStreamParser.prototype.processTag_ = function(tag) { 163 base.debug.assert(tag.charAt(0) == '<'); 164 base.debug.assert(tag.charAt(tag.length - 1) == '>'); 165 166 this.currentStanza_ += tag; 167 168 var openTag = tag.charAt(1) != '/'; 169 if (openTag) { 170 ++this.depth_; 171 if (this.depth_ == 1) { 172 this.startTag_ = this.currentStanza_; 173 this.currentStanza_ = ''; 174 175 // Create end tag matching the start. 176 var tagName = 177 this.startTag_.substr(1, this.startTag_.length - 2).split(' ', 1)[0]; 178 this.startTagEnd_ = '</' + tagName + '>'; 179 180 // Try parsing start together with the end 181 var parsed = this.parseTag_(this.startTag_ + this.startTagEnd_); 182 if (!parsed) { 183 this.processError_('Failed to parse start tag: ' + this.startTag_); 184 return; 185 } 186 } 187 } 188 189 var closingTag = 190 (tag.charAt(1) == '/') || (tag.charAt(tag.length - 2) == '/'); 191 if (closingTag) { 192 // The first start tag is not expected to be closed. 193 if (this.depth_ <= 1) { 194 this.processError_('Unexpected closing tag: ' + tag) 195 return; 196 } 197 --this.depth_; 198 if (this.depth_ == 1) { 199 this.processCompleteStanza_(); 200 this.currentStanza_ = ''; 201 } 202 } 203} 204 205/** 206 * @private 207 */ 208remoting.XmppStreamParser.prototype.processCompleteStanza_ = function() { 209 var stanza = this.startTag_ + this.currentStanza_ + this.startTagEnd_; 210 var parsed = this.parseTag_(stanza); 211 if (!parsed) { 212 this.processError_('Failed to parse stanza: ' + this.currentStanza_); 213 return; 214 } 215 this.onStanzaCallback_(parsed.firstElementChild); 216} 217 218/** 219 * @param {string} text 220 * @private 221 */ 222remoting.XmppStreamParser.prototype.processError_ = function(text) { 223 this.onErrorCallback_(text); 224 this.error_ = true; 225} 226 227/** 228 * Helper to extract and decode |bytes| bytes from |data_|. Returns NULL in case 229 * the buffer contains invalidUTF-8. 230 * 231 * @param {number} bytes Specifies how many bytes should be extracted. 232 * @returns {string?} 233 * @private 234 */ 235remoting.XmppStreamParser.prototype.extractStringFromBuffer_ = function(bytes) { 236 var result = ''; 237 try { 238 result = base.decodeUtf8(this.data_.slice(0, bytes)); 239 } catch (exception) { 240 this.processError_('Received invalid UTF-8 data.'); 241 result = null; 242 } 243 this.data_ = this.data_.slice(bytes); 244 return result; 245} 246 247/** 248 * @param {string} text 249 * @return {Element} 250 * @private 251 */ 252remoting.XmppStreamParser.prototype.parseTag_ = function(text) { 253 /** @type {Document} */ 254 var result = new DOMParser().parseFromString(text, 'text/xml'); 255 if (result.querySelector('parsererror') != null) 256 return null; 257 return result.firstElementChild; 258} 259