#!/usr/bin/env python3 """Independent TCP integration tests for integrated Rust XMPP modules (isolated data).""" import base64, json, signal, socket, ssl, subprocess, tempfile, time from pathlib import Path import xml.etree.ElementTree as ET ROOT=Path(__file__).resolve().parents[1] BIN=ROOT/'target/release/rust-xmpp' C='jabber:client'; S='http://etherx.jabber.org/streams' OPEN=f"" def local(e):return e.tag.split('}')[-1] class Client: def __init__(self,port,cert,user,password,resource='main',bind=True): self.sock=socket.create_connection(('127.0.0.1',port),timeout=5);self.buf=b'';self.queue=[];self.user=user;self.port=port;self.cert=cert;self.password=password self.send(OPEN);self.until(b'');self.send("");self.until(b'/>') self.sock=ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock,server_hostname='localhost');self.send(OPEN);self.until(b'') payload=base64.b64encode(f'\0{user}\0{password}'.encode()).decode();self.send(f"{payload}");self.until(b'/>');self.send(OPEN);self.until(b'') self.jid=f'{user}@localhost/{resource}' if bind:self.send(f"{resource}");self.iq('bind') def send(self,s):self.sock.sendall(s.encode()) def until(self,mark): while mark not in self.buf: chunk=self.sock.recv(65536) if not chunk:raise RuntimeError('EOF '+repr(self.buf)) self.buf+=chunk end=self.buf.index(mark)+len(mark);out,self.buf=self.buf[:end],self.buf[end:];return out def read(self): parser=ET.XMLPullParser(events=('start','end'));parser.feed(f"");list(parser.read_events());depth=0 while True: if not self.buf: self.buf=self.sock.recv(65536) if not self.buf:raise RuntimeError('EOF') byte,self.buf=self.buf[:1],self.buf[1:];parser.feed(byte) # Expat reparse deferral may withhold a short end-tag when fed bytewise. # Flush at markup boundaries so an empty IQ result needs no following stanza. if byte==b'>' and hasattr(parser,'flush'):parser.flush() for event,node in parser.read_events(): depth+=1 if event=='start' else -1 if event=='end' and depth==0:return node def take(self,predicate): for i,e in enumerate(self.queue): if predicate(e):return self.queue.pop(i) while True: e=self.read() if predicate(e):return e self.queue.append(e) def iq(self,id,error=False): e=self.take(lambda e:local(e)=='iq' and e.get('id')==id and e.get('type') in ('result','error')) assert e.get('type')==('error' if error else 'result'),ET.tostring(e) return e def sync(self):self.send("");self.iq('sync') def available(self):self.send('');self.sync() def body(self,text):return self.take(lambda e:local(e)=='message' and any(local(c)=='body' and c.text==text for c in e)) def close(self):self.sock.close() with tempfile.TemporaryDirectory(prefix='rust-xmpp-network-') as folder: path=Path(folder);port=socket.socket();port.bind(('127.0.0.1',0));portnum=port.getsockname()[1];port.close() subprocess.run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-keyout',str(path/'key.pem'),'-out',str(path/'cert.pem'),'-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) modules=['tls','saslauth','sasl2','sasl_ssdp','roster','disco','private','vcard_legacy','blocklist','mam','pep','bookmarks','smacks','carbons','ping','version','register','cloud_notify','time','uptime','csi_simple'] config={'global':{'data_path':str(path/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[portnum],'s2s_ports':[],'http_ports':[],'https_ports':[],'allow_registration':False,'default_archive_policy':True,'modules_enabled':modules},'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256','ssl':{'key':str(path/'key.pem'),'certificate':str(path/'cert.pem')}}},'conference.localhost':{'component':'muc','options':{'modules_enabled':['muc_mam'],'muc_room_default_persistent':True}},'pubsub.localhost':{'component':'pubsub','options':{'restrict_node_creation':False}}}} (path/'config.json').write_text(json.dumps(config)) for user in ['alice','bob']: subprocess.run([str(BIN),'create-user',str(path/'config.json'),user,'--password-stdin'],input=f'{user}-secret\n',text=True,check=True,stdout=subprocess.DEVNULL) server=subprocess.Popen([str(BIN),'serve',str(path/'config.json'),'--experimental'],stdout=subprocess.PIPE,stderr=subprocess.PIPE);clients=[] try: for _ in range(100): if server.poll() is not None:raise RuntimeError(server.stderr.read().decode()) try:s=socket.create_connection(('127.0.0.1',portnum),timeout=.1);s.close();break except OSError:time.sleep(.05) a=Client(portnum,path/'cert.pem','alice','alice-secret');b=Client(portnum,path/'cert.pem','bob','bob-secret');clients=[a,b];a.available();b.available() a.send("Friends");a.iq('roster') a.send("");assert any(e.get('jid')=='bob@localhost' for e in a.iq('getroster').iter()) a.send("");b.take(lambda e:local(e)=='presence' and e.get('type')=='subscribe') b.send("");b.sync();a.sync() a.send("");assert any(e.get('subscription')=='to' for e in a.iq('substate').iter()) a.send(f"") ask=b.take(lambda e:local(e)=='iq' and e.get('id')=='client-iq');assert ask.get('from')==a.jid b.send(f"Bob Client");assert a.iq('client-iq').get('from')==b.jid a.send("archived");b.body('archived') a.send("resource fallback");assert b.body('resource fallback').get('to')=='bob@localhost/obsolete-resource' # Inactive CSI holds chat-state noise, but a real message flushes it in order. b.send("");time.sleep(.05) a.send("");a.sync() assert not b.buf,repr(b.buf) b.sock.settimeout(.15) try: unexpected=b.sock.recv(1);raise AssertionError(('CSI leaked unimportant output',unexpected)) except socket.timeout:pass finally:b.sock.settimeout(5) a.send("CSI flush") noise=b.take(lambda e:e.get('id')=='csi-noise');assert any(n.tag=='{urn:xmpp:delay}delay' for n in noise) b.body('CSI flush') b.send("");time.sleep(.05) a.send("");a.sync();time.sleep(.05) b.send(' ');b.take(lambda e:e.get('id')=='csi-keepalive') b.send("") a.send("");a.iq('mam');assert any(any(n.tag=='{urn:xmpp:mam:2}result' for n in e) for e in a.queue) b.send("");b.iq('block') a.send("blocked");a.sync() b.send("");b.iq('unblock') a.send("");a.iq('pep') a.send("");assert any(n.tag=='{eu.siacs.conversations.axolotl}device' for n in a.iq('pepget').iter()) for c,nick in [(a,'Alice'),(b,'Bob')]: c.send(f"");c.take(lambda e:local(e)=='presence' and any(n.get('code')=='110' for n in e.iter())) if c is a:c.send("");c.iq('unlock') a.send("room chat");assert b.body('room chat').get('from')=='room@conference.localhost/Alice' b.close();clients.remove(b);time.sleep(.1) a.send("offline replay");a.sync() a.send("discard headline");a.sync() assert not any(e.get('id')=='offline-headline' and e.get('type')=='error' for e in a.queue),'offline headline must be silently discarded' # no-permanent-store forbids MAM, but must not discard transient offline delivery. a.send("transient offline") # Bodyless receipts are also messages handled by native mod_message/mod_offline. a.send("");a.sync() b=Client(portnum,path/'cert.pem','bob','bob-secret','second');clients.append(b);b.available();b.body('offline replay');b.body('transient offline') receipt=b.take(lambda e:e.get('id')=='offline-receipt');assert any(n.tag=='{urn:xmpp:receipts}received' for n in receipt) b.send("");b.iq('offline-mam') assert not any(n.tag=='{jabber:client}body' and n.text=='transient offline' for e in b.queue for n in e.iter()),'no-permanent-store leaked into MAM' b.send("");enabled=b.take(lambda e:e.tag=='{urn:xmpp:sm:3}enabled');resume=enabled.get('id');b.close();clients.remove(b);time.sleep(.1) a.send("resume replay");a.sync() b=Client(portnum,path/'cert.pem','bob','bob-secret',bind=False);clients.append(b);b.send(f"");b.take(lambda e:e.tag=='{urn:xmpp:sm:3}resumed');b.body('resume replay');b.send("");b.sync() print('PASS: integrated TCP roster/subscriptions, client IQ routing, CSI buffering/keepalive flush, MAM, blocking, OMEMO PEP, MUC, offline replay and SM resumption') finally: for c in clients:c.close() server.send_signal(signal.SIGTERM) try:server.wait(timeout=5) except subprocess.TimeoutExpired:server.kill();server.wait() assert server.returncode==0, f'SIGTERM shutdown failed: {server.returncode}: '+server.stderr.read().decode() print('PASS: graceful SIGTERM shutdown')