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