initial tls, sasl and bind stages impl
This commit is contained in:
commit
aa7a5e89ec
35
project.godot
Normal file
35
project.godot
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
; Engine configuration file.
|
||||||
|
; It's best edited using the editor UI and not directly,
|
||||||
|
; since the parameters that go here are not all obvious.
|
||||||
|
;
|
||||||
|
; Format:
|
||||||
|
; [section] ; section goes between []
|
||||||
|
; param=value ; assign values to parameters
|
||||||
|
|
||||||
|
config_version=4
|
||||||
|
|
||||||
|
[application]
|
||||||
|
|
||||||
|
config/name="tochie"
|
||||||
|
run/main_scene="res://scenes/App.tscn"
|
||||||
|
run/low_processor_mode=true
|
||||||
|
config/icon="res://icon.png"
|
||||||
|
|
||||||
|
[autoload]
|
||||||
|
|
||||||
|
Xml="*res://scenes/Xml.gd"
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
|
||||||
|
common/drop_mouse_on_gui_input_disabled=true
|
||||||
|
|
||||||
|
[physics]
|
||||||
|
|
||||||
|
common/enable_pause_aware_picking=true
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
|
||||||
|
quality/driver/driver_name="GLES2"
|
||||||
|
vram_compression/import_etc=true
|
||||||
|
vram_compression/import_etc2=false
|
||||||
|
environment/default_environment="res://default_env.tres"
|
6
scenes/App.gd
Normal file
6
scenes/App.gd
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
var connection = $Connections.establish_new_connection("poto.cafe", "veclavtalica", "-")
|
||||||
|
if connection == null:
|
||||||
|
push_error("Connection failed")
|
14
scenes/App.tscn
Normal file
14
scenes/App.tscn
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=3 format=2]
|
||||||
|
|
||||||
|
[ext_resource path="res://scenes/Connections.gd" type="Script" id=1]
|
||||||
|
[ext_resource path="res://scenes/App.gd" type="Script" id=2]
|
||||||
|
|
||||||
|
[node name="App" type="Node"]
|
||||||
|
script = ExtResource( 2 )
|
||||||
|
|
||||||
|
[node name="Shell" type="VBoxContainer" parent="."]
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
|
||||||
|
[node name="Connections" type="Node" parent="."]
|
||||||
|
script = ExtResource( 1 )
|
292
scenes/Connections.gd
Normal file
292
scenes/Connections.gd
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
class Connection extends Reference:
|
||||||
|
var stream: StreamPeer
|
||||||
|
var identity: String
|
||||||
|
var domain: String
|
||||||
|
var bare_jid: String
|
||||||
|
var jid: String
|
||||||
|
var id_counter: int = 0
|
||||||
|
|
||||||
|
func generate_id() -> int:
|
||||||
|
self.id_counter += 1
|
||||||
|
return hash(self.id_counter)
|
||||||
|
|
||||||
|
func establish_new_connection(domain: String, identity: String, password: String) -> Connection:
|
||||||
|
## XMPP uses unidirectional pipes for communication, which means
|
||||||
|
## multiple connections are open over different predefined ports.
|
||||||
|
var stream := StreamPeerTCP.new()
|
||||||
|
if stream.connect_to_host(domain, 5222) != OK:
|
||||||
|
push_error("Cannot establish client->server pipe to " + domain)
|
||||||
|
return null
|
||||||
|
|
||||||
|
while stream.get_status() == StreamPeerTCP.STATUS_CONNECTING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if stream.get_status() == StreamPeerTCP.STATUS_ERROR:
|
||||||
|
push_error("Cannot establish client->server pipe to " + domain)
|
||||||
|
return null
|
||||||
|
|
||||||
|
var result := Connection.new()
|
||||||
|
result.stream = stream
|
||||||
|
result.identity = identity
|
||||||
|
result.domain = domain
|
||||||
|
result.bare_jid = identity + '@' + domain
|
||||||
|
|
||||||
|
if _negotiate_connection(result, password) != OK:
|
||||||
|
return null
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# todo: Make it async
|
||||||
|
func _negotiate_connection(connection: Connection, password: String) -> int:
|
||||||
|
## See [9. Detailed Examples] of https://datatracker.ietf.org/doc/rfc6120/
|
||||||
|
var error: int = OK
|
||||||
|
|
||||||
|
error = _negotiate_tls(connection)
|
||||||
|
if error != OK: return error
|
||||||
|
|
||||||
|
error = _negotiate_sasl(connection, password)
|
||||||
|
if error != OK: return error
|
||||||
|
|
||||||
|
error = _bind_resource(connection)
|
||||||
|
if error != OK: return error
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
func _form_initial_message(domain: String, bare_jid: String) -> String:
|
||||||
|
# todo: XML builder interface, writing by hand is extremely error prone.
|
||||||
|
return """<?xml version='1.0'?><stream:stream
|
||||||
|
from='{bare_jid}' to='{domain}' version='1.0'
|
||||||
|
xml:lang='en' xmlns='jabber:client'
|
||||||
|
xmlns:stream='http://etherx.jabber.org/streams'>""".format({
|
||||||
|
"bare_jid": bare_jid.xml_escape(), "domain": domain.xml_escape()})
|
||||||
|
|
||||||
|
func _negotiate_tls(connection: Connection) -> int:
|
||||||
|
var error: int = OK
|
||||||
|
var response
|
||||||
|
var parsed_response = Xml.Parser.new()
|
||||||
|
|
||||||
|
## Step 1: Client initiates stream to server:
|
||||||
|
if connection.stream.put_data(_form_initial_message(
|
||||||
|
connection.domain, connection.bare_jid).to_utf8()) != OK:
|
||||||
|
return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Step 2: Server responds by sending a response stream header to client:
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
# If <stream:stream> is closed, - connection is closed too.
|
||||||
|
|
||||||
|
## Check that server response corresponds to what we ask for.
|
||||||
|
# todo: For conformity client must send closing stream tag on error.
|
||||||
|
if parsed_response.root.node_name != "stream:stream": return ERR_CANT_CONNECT
|
||||||
|
if parsed_response.root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT
|
||||||
|
if "to" in parsed_response.root.attributes:
|
||||||
|
if parsed_response.root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT
|
||||||
|
if parsed_response.root.attributes["version"] != "1.0": return ERR_CANT_CONNECT
|
||||||
|
# todo: Assert for namespaces.
|
||||||
|
|
||||||
|
## Step 3: Server sends stream features to client (only the STARTTLS
|
||||||
|
## extension at this point, which is mandatory-to-negotiate):
|
||||||
|
if parsed_response.root.children.size() == 0:
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
var features = parsed_response.root.take_named_child_element("stream:features")
|
||||||
|
if features == null: return ERR_CANT_CONNECT
|
||||||
|
var starttls = features.take_named_child_element("starttls")
|
||||||
|
if starttls == null or starttls.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-tls":
|
||||||
|
push_error("Connection declined, TLS is not a listed feature")
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
## Step 4: Client sends STARTTLS command to server:
|
||||||
|
if connection.stream.put_data("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>".to_utf8()) != OK:
|
||||||
|
return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Step 5: Server informs client that it is allowed to proceed:
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
# todo: Handle failure case.
|
||||||
|
|
||||||
|
var proceed = parsed_response.root.take_named_child_element("proceed")
|
||||||
|
if proceed == null: return ERR_CANT_CONNECT
|
||||||
|
if proceed.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-tls": return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
## Step 6: Client and server attempt to complete TLS negotiation over
|
||||||
|
## the existing TCP connection (see [TLS] for details).
|
||||||
|
var tls_stream := StreamPeerSSL.new()
|
||||||
|
error = tls_stream.connect_to_stream(connection.stream, true, connection.domain)
|
||||||
|
if error != OK:
|
||||||
|
push_error("Cannot establish client->server TLS connection")
|
||||||
|
return error
|
||||||
|
|
||||||
|
connection.stream = tls_stream
|
||||||
|
|
||||||
|
## Step 7 (alt): If TLS negotiation is unsuccessful, server closes TCP
|
||||||
|
## connection (thus, the stream negotiation process ends unsuccessfully
|
||||||
|
## and the parties do not move on to the next step):
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
func _negotiate_sasl(connection: Connection, password: String) -> int:
|
||||||
|
var response
|
||||||
|
var parsed_response = Xml.Parser.new()
|
||||||
|
|
||||||
|
## Step 7: If TLS negotiation is successful, client initiates a new
|
||||||
|
## stream to server over the TLS-protected TCP connection:
|
||||||
|
if connection.stream.put_data(_form_initial_message(
|
||||||
|
connection.domain, connection.bare_jid).to_utf8()) != OK:
|
||||||
|
return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Step 8: Server responds by sending a stream header to client along
|
||||||
|
## with any available stream features:
|
||||||
|
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Check that server response corresponds to what we ask for.
|
||||||
|
# todo: For conformity client must send closing stream tag on error.
|
||||||
|
if parsed_response.root.node_name != "stream:stream": return ERR_CANT_CONNECT
|
||||||
|
if parsed_response.root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT
|
||||||
|
if "to" in parsed_response.root.attributes:
|
||||||
|
if parsed_response.root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT
|
||||||
|
if parsed_response.root.attributes["version"] != "1.0": return ERR_CANT_CONNECT
|
||||||
|
# todo: Assert for namespaces.
|
||||||
|
|
||||||
|
if parsed_response.root.children.size() == 0:
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
var features = parsed_response.root.take_named_child_element("stream:features")
|
||||||
|
if features == null: return ERR_CANT_CONNECT
|
||||||
|
var mechanisms = features.take_named_child_element("mechanisms")
|
||||||
|
if mechanisms == null or mechanisms.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-sasl":
|
||||||
|
push_error("Connection declined, mechanisms is not a listed feature")
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
## We only support PLAIN mechanism as it's sufficient over TLS.
|
||||||
|
var plain_found := false
|
||||||
|
for machanism in mechanisms.children:
|
||||||
|
if machanism.is_element() and machanism.node_name == "mechanism":
|
||||||
|
if machanism.children[0].data == "PLAIN":
|
||||||
|
plain_found = true
|
||||||
|
break
|
||||||
|
|
||||||
|
if not plain_found:
|
||||||
|
push_error("Connection declined, mechanisms don't include PLAIN method")
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
## Step 9: Client selects an authentication mechanism, including initial response data:
|
||||||
|
## https://datatracker.ietf.org/doc/html/rfc4616
|
||||||
|
if connection.stream.put_data((
|
||||||
|
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>%s</auth>" %
|
||||||
|
[_plain_auth_message(connection.identity, password)]).to_utf8()) != OK:
|
||||||
|
return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## (Skipped steps related to another auth machanism)
|
||||||
|
|
||||||
|
## Step 12: Server informs client of success, including additional data
|
||||||
|
## with success:
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
var success = parsed_response.root.take_named_child_element("success")
|
||||||
|
if success == null or success.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-sasl":
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
func _bind_resource(connection: Connection, resource: String = "tochie-facade") -> int:
|
||||||
|
var response
|
||||||
|
var parsed_response = Xml.Parser.new()
|
||||||
|
|
||||||
|
## Step 13: Client initiates a new stream to server:
|
||||||
|
if connection.stream.put_data(_form_initial_message(
|
||||||
|
connection.domain, connection.bare_jid).to_utf8()) != OK:
|
||||||
|
return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Step 14: Server responds by sending a stream header to client along
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Check that server response corresponds to what we ask for.
|
||||||
|
# todo: For conformity client must send closing stream tag on error.
|
||||||
|
if parsed_response.root.node_name != "stream:stream": return ERR_CANT_CONNECT
|
||||||
|
if parsed_response.root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT
|
||||||
|
if "to" in parsed_response.root.attributes:
|
||||||
|
if parsed_response.root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT
|
||||||
|
if parsed_response.root.attributes["version"] != "1.0": return ERR_CANT_CONNECT
|
||||||
|
# todo: Assert for namespaces.
|
||||||
|
|
||||||
|
if parsed_response.root.children.size() == 0:
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
var features = parsed_response.root.take_named_child_element("stream:features")
|
||||||
|
if features == null: return ERR_CANT_CONNECT
|
||||||
|
var bind = features.take_named_child_element("bind")
|
||||||
|
if bind == null or bind.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-bind":
|
||||||
|
push_error("Connection declined, bind is not a listed feature")
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
## Step 15: Client binds a resource:
|
||||||
|
var iq_id := connection.generate_id()
|
||||||
|
if connection.stream.put_data("""<iq id='{id}' type='set'>
|
||||||
|
<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'>
|
||||||
|
<resource>{resource}</resource>
|
||||||
|
</bind></iq>""".format({
|
||||||
|
"id": iq_id,
|
||||||
|
"resource": resource
|
||||||
|
}).to_utf8()) != OK:
|
||||||
|
return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
response = _wait_blocking_for_utf8_data(connection.stream)
|
||||||
|
if response == null: return ERR_CONNECTION_ERROR
|
||||||
|
if parsed_response.parse_a_bit(response): return ERR_CONNECTION_ERROR
|
||||||
|
|
||||||
|
## Step 16: Server accepts submitted resourcepart and informs client of
|
||||||
|
## successful resource binding:
|
||||||
|
var iq = parsed_response.root.take_named_child_element("iq")
|
||||||
|
if iq.attributes["id"] != String(iq_id):
|
||||||
|
push_error("iq id mismatch, expected: " + String(iq_id) + " but got: " + iq.attributes["id"])
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
bind = iq.take_named_child_element("bind")
|
||||||
|
if bind == null or bind.attributes["xmlns"] != "urn:ietf:params:xml:ns:xmpp-bind":
|
||||||
|
return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
var jid = bind.take_named_child_element("jid")
|
||||||
|
if jid == null: return ERR_CANT_CONNECT
|
||||||
|
|
||||||
|
connection.jid = jid.children[0].data
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
func _plain_auth_message(identity: String, password: String) -> String:
|
||||||
|
var raw = PoolByteArray([0])
|
||||||
|
raw.append_array(identity.to_utf8() + PoolByteArray([0]))
|
||||||
|
raw.append_array(password.to_utf8())
|
||||||
|
return Marshalls.raw_to_base64(raw)
|
||||||
|
|
||||||
|
func _wait_blocking_for_utf8_data(stream: StreamPeer, timeout_ms: int = 1000): # -> String or null:
|
||||||
|
var enter_time := OS.get_system_time_msecs()
|
||||||
|
|
||||||
|
if stream is StreamPeerSSL: stream.poll()
|
||||||
|
var available_bytes := stream.get_available_bytes()
|
||||||
|
|
||||||
|
while available_bytes == 0:
|
||||||
|
if OS.get_system_time_msecs() - enter_time >= timeout_ms:
|
||||||
|
return null
|
||||||
|
if stream is StreamPeerSSL: stream.poll()
|
||||||
|
available_bytes = stream.get_available_bytes()
|
||||||
|
|
||||||
|
return stream.get_utf8_string(available_bytes)
|
92
scenes/Xml.gd
Normal file
92
scenes/Xml.gd
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
class XmlNode extends Reference:
|
||||||
|
func is_element() -> bool: return false
|
||||||
|
func is_text() -> bool: return false
|
||||||
|
|
||||||
|
class XmlElement extends XmlNode:
|
||||||
|
var node_name: String
|
||||||
|
var attributes: Dictionary # String to String
|
||||||
|
var children: Array # of XmlNode
|
||||||
|
|
||||||
|
func is_element() -> bool: return true
|
||||||
|
|
||||||
|
func take_named_child_element(p_node_name: String) -> XmlElement:
|
||||||
|
for idx in range(self.children.size()):
|
||||||
|
var child := self.children[idx] as XmlElement
|
||||||
|
if child != null and child.node_name == p_node_name:
|
||||||
|
self.children.remove(idx)
|
||||||
|
return child
|
||||||
|
return null
|
||||||
|
|
||||||
|
func as_string(level = 0) -> String:
|
||||||
|
var result = '\t'.repeat(level) + "XmlElement \"%s\"" % self.node_name
|
||||||
|
if self.attributes.size() > 0:
|
||||||
|
result += ", Attributes: " + String(self.attributes)
|
||||||
|
for child in self.children:
|
||||||
|
result += '\n' + '\t '.repeat(level) + child.as_string(level + 1)
|
||||||
|
return result
|
||||||
|
|
||||||
|
class XmlText extends XmlNode:
|
||||||
|
var data: String
|
||||||
|
|
||||||
|
func is_text() -> bool: return true
|
||||||
|
|
||||||
|
func as_string(level = 0) -> String:
|
||||||
|
return "XmlText \"%s\"" % self.data
|
||||||
|
|
||||||
|
## Wrapper over XMLParser for TCP stream oriented XML data.
|
||||||
|
# todo: Save namespaces.
|
||||||
|
class Parser extends Reference:
|
||||||
|
var root: XmlElement = null
|
||||||
|
var _element_stack: Array # of XmlElement
|
||||||
|
|
||||||
|
## Returns true if root element is closed, otherwise more tags are expected to come later.
|
||||||
|
func parse_a_bit(data): # -> bool or Error:
|
||||||
|
var error: int
|
||||||
|
var parser := XMLParser.new()
|
||||||
|
if data is String: data = data.to_utf8()
|
||||||
|
error = parser.open_buffer(data)
|
||||||
|
if error != OK:
|
||||||
|
push_error("Error opening a buffer for XML parsing")
|
||||||
|
return error
|
||||||
|
|
||||||
|
while parser.read() == OK:
|
||||||
|
if parser.get_node_type() == XMLParser.NODE_ELEMENT:
|
||||||
|
var element := XmlElement.new()
|
||||||
|
|
||||||
|
if self.root != null:
|
||||||
|
self._element_stack[self._element_stack.size() - 1].children.push_back(element)
|
||||||
|
else: self.root = element
|
||||||
|
|
||||||
|
if not parser.is_empty():
|
||||||
|
self._element_stack.push_back(element)
|
||||||
|
|
||||||
|
element.node_name = parser.get_node_name()
|
||||||
|
var attribute_count := parser.get_attribute_count()
|
||||||
|
for idx in range(attribute_count):
|
||||||
|
element.attributes[parser.get_attribute_name(idx)] = parser.get_attribute_value(idx)
|
||||||
|
|
||||||
|
elif parser.get_node_type() == XMLParser.NODE_ELEMENT_END:
|
||||||
|
if self._element_stack.size() == 0:
|
||||||
|
push_error("Closing element </%s> closes nothing" % [parser.get_node_name()])
|
||||||
|
return ERR_PARSE_ERROR
|
||||||
|
|
||||||
|
var popped := self._element_stack.pop_back() as XmlElement
|
||||||
|
if popped.node_name != parser.get_node_name():
|
||||||
|
push_error("Element <%s> closes sooner than <%s>" % [parser.get_node_name(), popped.node_name])
|
||||||
|
return ERR_PARSE_ERROR
|
||||||
|
|
||||||
|
elif parser.get_node_type() == XMLParser.NODE_TEXT:
|
||||||
|
if self._element_stack.size() == 0:
|
||||||
|
push_error("Text node should be a child of an element")
|
||||||
|
return ERR_PARSE_ERROR
|
||||||
|
|
||||||
|
var text = XmlText.new()
|
||||||
|
text.data = parser.get_node_data()
|
||||||
|
self._element_stack[self._element_stack.size() - 1].children.push_back(text)
|
||||||
|
|
||||||
|
else:
|
||||||
|
push_warning("Node type unimplemented and ignored")
|
||||||
|
|
||||||
|
return self._element_stack.size() == 0
|
Loading…
Reference in New Issue
Block a user