initial commit
This commit is contained in:
commit
c260d7bab8
235 files changed
+49786
No files matched your search
@@ -0,0 +1,54 @@
|
||||
#!/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"<stream:stream xmlns='{C}' xmlns:stream='{S}' to='localhost' version='1.0'>"
|
||||
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'</stream:features>');self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");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'</stream:features>')
|
||||
payload=base64.b64encode(f'\0{user}\0{password}'.encode()).decode();self.send(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{payload}</auth>");self.until(b'/>');self.send(OPEN);self.post_auth_features=self.until(b'</stream:features>')
|
||||
self.jid=f'{user}@localhost/{resource}'
|
||||
if bind:self.send(f"<iq type='set' id='bind'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>{resource}</resource></bind></iq>");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"<root xmlns='{C}' xmlns:stream='{S}'>");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("<iq type='get' id='sync'><ping xmlns='urn:xmpp:ping'/></iq>");self.iq('sync')
|
||||
def available(self):self.send('<presence/>');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()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
use rust_xmpp::{
|
||||
admin::{AdminBackend, AdminServer, AdminSnapshot, query},
|
||||
auth,
|
||||
};
|
||||
use std::{collections::BTreeMap, sync::Arc, time::Duration};
|
||||
#[tokio::test]
|
||||
#[ignore = "requires AF_UNIX socket permission; run on target host with --ignored"]
|
||||
async fn prosody_wire_commands_password_prompt_and_safe_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("prosody.sock");
|
||||
let backend = AdminBackend {
|
||||
data: dir.path().into(),
|
||||
hosts: BTreeMap::from([("example.com".into(), "SHA-256".into())]),
|
||||
modules: BTreeMap::from([(
|
||||
"example.com".into(),
|
||||
vec!["admin_shell".into(), "tls".into()],
|
||||
)]),
|
||||
snapshot: Arc::new(|| AdminSnapshot {
|
||||
uptime: Duration::from_secs(123),
|
||||
sessions: vec!["alice@example.com/r".into()],
|
||||
}),
|
||||
delete_user: None,
|
||||
disconnect_user: None,
|
||||
};
|
||||
let server = AdminServer::bind(&socket, backend.clone()).unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(AdminServer::bind(&socket, backend).is_err());
|
||||
let task = tokio::spawn(server.serve());
|
||||
let result = query(&socket, "server:status()", None).await.unwrap();
|
||||
assert!(!result.error);
|
||||
assert_eq!(result.message, "OK: running");
|
||||
let result = query(&socket, "{\"c2s\",\"count\"}", None).await.unwrap();
|
||||
assert_eq!(result.message, "OK: Total: 1 clients");
|
||||
{
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut client = tokio::net::UnixStream::connect(&socket).await.unwrap();
|
||||
client.write_all(b"<a:stream xmlns:a='http://etherx.jabber.org/streams' xmlns='xmpp:prosody.im/admin' xmlns:q='xmpp:prosody.im/admin'><q:repl-input>server:version()</q:repl-input>").await.unwrap();
|
||||
let mut framer = rust_xmpp::wire::Framer::default();
|
||||
framer.next(&mut client).await.unwrap();
|
||||
let response = framer.next(&mut client).await.unwrap();
|
||||
assert!(response.contains("rust-xmpp"));
|
||||
}
|
||||
let result = query(&socket, "c2s:show()", None).await.unwrap();
|
||||
assert_eq!(result.output, vec!["alice@example.com/r"]);
|
||||
let result = query(
|
||||
&socket,
|
||||
"user:create('alice@example.com')",
|
||||
Some("initial<&password"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.error, "{}", result.message);
|
||||
assert!(
|
||||
auth::verify(
|
||||
dir.path(),
|
||||
"example.com",
|
||||
"alice",
|
||||
"initial<&password",
|
||||
"SHA-256"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let result = query(
|
||||
&socket,
|
||||
"user:password('alice@example.com','changed')",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.error);
|
||||
assert!(auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
|
||||
let result = query(&socket, "user:disable('alice@example.com')", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.error);
|
||||
assert!(!auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
|
||||
let result = query(&socket, "user:enable('alice@example.com')", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.error);
|
||||
assert!(auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
|
||||
for command in [
|
||||
"module:reload('tls')",
|
||||
"os.execute('echo unauthorized')",
|
||||
"user:delete('alice@example.com')",
|
||||
"user:create('bob@example.com',nil,'prosody:admin')",
|
||||
] {
|
||||
let result = query(&socket, command, None).await.unwrap();
|
||||
assert!(result.error, "{command}");
|
||||
}
|
||||
let result = query(&socket, "user:password('alice@example.com')", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.error);
|
||||
assert!(auth::verify(dir.path(), "example.com", "alice", "changed", "SHA-256").unwrap());
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
assert!(!socket.exists());
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent Android SCRAM compatibility regression over real STARTTLS.
|
||||
|
||||
Usage: python3 tests/android_sasl_network.py BINARY [--expect-060-failure]
|
||||
The selector mirrors Conversations ChannelBinding.best (Codeberg master,
|
||||
retrieved 2026-10-20). TLS 1.3 network cases use the certificate binding;
|
||||
the faithful Android selector's exporter preference is checked separately.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import itertools
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
SASL = 'urn:ietf:params:xml:ns:xmpp-sasl'
|
||||
SASL2 = 'urn:xmpp:sasl:2'
|
||||
CB = 'urn:xmpp:sasl-cb:0'
|
||||
OPEN = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>"
|
||||
|
||||
|
||||
def android_binding(bindings, version):
|
||||
if version == 'TLSv1.3' and 'tls-exporter' in bindings:
|
||||
return 'tls-exporter'
|
||||
if version in ('TLSv1', 'TLSv1.1', 'TLSv1.2') and 'tls-unique' in bindings:
|
||||
return 'tls-unique'
|
||||
if 'tls-server-end-point' in bindings:
|
||||
return 'tls-server-end-point'
|
||||
if not bindings:
|
||||
return 'tls-exporter' if version == 'TLSv1.3' else 'tls-unique'
|
||||
return None
|
||||
|
||||
|
||||
def b64(data):
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, port, cert, version):
|
||||
self.sock = socket.create_connection(('127.0.0.1', port), timeout=5)
|
||||
self.buffer = b''
|
||||
self.send(OPEN)
|
||||
self.until(b'</stream:features>')
|
||||
self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
||||
assert b'proceed' in self.until(b'/>')
|
||||
context = ssl.create_default_context(cafile=str(cert))
|
||||
context.minimum_version = context.maximum_version = version
|
||||
self.sock = context.wrap_socket(self.sock, server_hostname='localhost')
|
||||
self.send(OPEN)
|
||||
raw = self.until(b'</stream:features>')
|
||||
raw = raw[raw.index(b'<stream:features'):]
|
||||
self.features = ET.fromstring(b"<root xmlns:stream='http://etherx.jabber.org/streams'>" + raw + b'</root>')
|
||||
|
||||
def send(self, value):
|
||||
self.sock.sendall(value.encode())
|
||||
|
||||
def until(self, marker):
|
||||
while marker not in self.buffer:
|
||||
chunk = self.sock.recv(65536)
|
||||
assert chunk, self.buffer
|
||||
self.buffer += chunk
|
||||
end = self.buffer.index(marker) + len(marker)
|
||||
result, self.buffer = self.buffer[:end], self.buffer[end:]
|
||||
return result
|
||||
|
||||
def stanza(self):
|
||||
# SASL stanzas have no unclosed stream prefix after features.
|
||||
parser = ET.XMLPullParser(events=('start', 'end'))
|
||||
depth = 0
|
||||
while True:
|
||||
byte = self.until(b'>')
|
||||
parser.feed(byte)
|
||||
if 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 auth(self, hash_name, sasl2, wrong_binding=False, expect_old=False, ssdp=True):
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
parent = self.features.find('.//{' + ns + '}' + ('authentication' if sasl2 else 'mechanisms'))
|
||||
mechanisms = [n.text for n in parent.findall('{' + ns + '}mechanism')]
|
||||
bindings = [n.get('type') for n in self.features.findall('.//{' + CB + '}channel-binding')]
|
||||
selected = android_binding(bindings, self.sock.version())
|
||||
if expect_old:
|
||||
assert bindings == ['tls-exporter'] and selected is None, bindings
|
||||
elif self.sock.version() == 'TLSv1.3':
|
||||
assert selected == 'tls-exporter', bindings
|
||||
# CPython does not expose TLS exporters. Exercise another real PLUS
|
||||
# binding on TLS 1.3, while retaining the selector assertion above.
|
||||
assert 'tls-server-end-point' in bindings
|
||||
selected = 'tls-server-end-point'
|
||||
else:
|
||||
assert selected == 'tls-server-end-point', bindings
|
||||
mechanism = 'SCRAM-' + hash_name + ('-PLUS' if selected else '')
|
||||
assert mechanism in mechanisms
|
||||
gs2 = ('p=' + selected + ',,') if selected else 'y,,'
|
||||
bare = 'n=alice,r=android-independent-client-nonce'
|
||||
initial = b64((gs2 + bare).encode())
|
||||
if sasl2:
|
||||
self.send(f"<authenticate xmlns='{ns}' mechanism='{mechanism}'><initial-response>{initial}</initial-response></authenticate>")
|
||||
else:
|
||||
self.send(f"<auth xmlns='{ns}' mechanism='{mechanism}'>{initial}</auth>")
|
||||
challenge = self.stanza()
|
||||
if expect_old:
|
||||
assert challenge.tag == '{' + ns + '}failure', ET.tostring(challenge)
|
||||
assert challenge.find('{' + SASL + '}not-authorized') is not None
|
||||
return
|
||||
assert challenge.tag == '{' + ns + '}challenge', ET.tostring(challenge)
|
||||
first = base64.b64decode(challenge.text).decode()
|
||||
attrs = dict(item.split('=', 1) for item in first.split(','))
|
||||
digest = hash_name.lower().replace('-', '')
|
||||
expected_ssdp = '\x1e'.join(sorted(mechanisms)) + '\x1f' + '\x1e'.join(sorted(bindings))
|
||||
if ssdp:
|
||||
assert attrs['h'] == b64(hashlib.new(digest, expected_ssdp.encode()).digest())
|
||||
else:
|
||||
assert 'h' not in attrs
|
||||
assert attrs['r'].startswith(bare.split('r=', 1)[1])
|
||||
assert int(attrs['i']) >= 4096
|
||||
# openssl req creates a SHA256-signed certificate; RFC 5929 hashes DER.
|
||||
binding = hashlib.sha256(self.sock.getpeercert(binary_form=True)).digest()
|
||||
if wrong_binding:
|
||||
binding = bytes([binding[0] ^ 1]) + binding[1:]
|
||||
final = 'c=' + b64(gs2.encode() + binding) + ',r=' + attrs['r']
|
||||
salted = hashlib.pbkdf2_hmac(digest, b'secret', base64.b64decode(attrs['s']), int(attrs['i']))
|
||||
mac = lambda key, message: hmac.new(key, message, digest).digest()
|
||||
client_key = mac(salted, b'Client Key')
|
||||
auth = (bare + ',' + first + ',' + final).encode()
|
||||
signature = mac(hashlib.new(digest, client_key).digest(), auth)
|
||||
proof = bytes(a ^ b for a, b in zip(client_key, signature))
|
||||
self.send(f"<response xmlns='{ns}'>{b64((final + ',p=' + b64(proof)).encode())}</response>")
|
||||
result = self.stanza()
|
||||
if wrong_binding:
|
||||
assert result.tag == '{' + ns + '}failure', ET.tostring(result)
|
||||
assert result.find('{' + SASL + '}not-authorized') is not None
|
||||
else:
|
||||
assert result.tag == '{' + ns + '}success', ET.tostring(result)
|
||||
payload = result.findtext('{' + ns + '}additional-data') if sasl2 else result.text
|
||||
expected = 'v=' + b64(mac(mac(salted, b'Server Key'), auth))
|
||||
assert base64.b64decode(payload).decode() == expected
|
||||
|
||||
|
||||
def run(binary, old=False):
|
||||
assert android_binding(['tls-exporter'], 'TLSv1.2') is None
|
||||
assert android_binding(['tls-exporter'], 'TLSv1.3') == 'tls-exporter'
|
||||
for hash_name, ssdp in itertools.product(('SHA-1', 'SHA-256'), (True,) if old else (False, True)):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-android-sasl-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2'] + (['sasl_ssdp'] if ssdp else [])}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': hash_name, 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert process.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
versions = [ssl.TLSVersion.TLSv1_2] if old else [ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3]
|
||||
for version in versions:
|
||||
for sasl2 in (False, True):
|
||||
for wrong in ([False] if old else [False, True]):
|
||||
connection = Connection(port, root/'cert.pem', version)
|
||||
try:
|
||||
connection.auth(hash_name, sasl2, wrong, old, ssdp)
|
||||
finally:
|
||||
connection.sock.close()
|
||||
except BaseException:
|
||||
log.flush(); log.seek(0); print(log.read())
|
||||
raise
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill(); process.wait()
|
||||
print('PASS: reproduced 0.6.0 TLS1.2 Android y,, rejection' if old else 'PASS: Android TLS1.2 selector, TLS1.2/1.3 SCRAM SHA1/256 PLUS, SASL1/2, SSDP on/off and invalid binding rejection')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()), '--expect-060-failure' in sys.argv[2:])
|
||||
@@ -0,0 +1,591 @@
|
||||
use rust_xmpp::{archive, mam, storage};
|
||||
fn query(root: &std::path::Path, xml: &str) -> Vec<String> {
|
||||
let d = roxmltree::Document::parse(xml).unwrap();
|
||||
mam::handle(
|
||||
root,
|
||||
"example.org",
|
||||
"alice@example.org/phone",
|
||||
d.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies
|
||||
}
|
||||
#[test]
|
||||
fn legacy_records_binary_cache_and_roundtrip() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list");
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p,b"item({name='message';attr={xmlns='jabber:client';stamp='2026-01-01T00:00:00Z'};key='old';with='bob@example.org';{name='body';attr={};'hello'}});\n").unwrap();
|
||||
std::fs::write(p.with_extension("lidx"), b"binary cache").unwrap();
|
||||
let rows = archive::load(t.path(), "example.org", "alice").unwrap();
|
||||
assert_eq!(rows[0].id, "old");
|
||||
assert!(rows[0].xml.contains("hello"));
|
||||
assert!(!rows[0].xml.contains("stamp="));
|
||||
archive::archive_message(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"<message xmlns='jabber:client'><body>Привет & test</body></message>",
|
||||
1767225601.,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!p.with_extension("lidx").exists());
|
||||
let rows = archive::load(t.path(), "example.org", "alice").unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows[1].xml.contains("Привет"));
|
||||
// Verify written syntax through actual Lua item callback, independent of Rust decoder.
|
||||
let lua = mlua::Lua::new();
|
||||
let code = format!(
|
||||
"local count=0; local function item(v) assert(v.name=='message'); assert(v.attr.stamp);count=count+1 end; {}; return count",
|
||||
std::fs::read_to_string(p).unwrap()
|
||||
);
|
||||
assert_eq!(lua.load(&code).eval::<usize>().unwrap(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn mam_paging_filters_and_forbidden() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut ids = vec![];
|
||||
for i in 0..4 {
|
||||
ids.push(
|
||||
archive::archive_message(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
if i % 2 == 0 {
|
||||
"bob@example.org"
|
||||
} else {
|
||||
"carol@example.org"
|
||||
},
|
||||
"<message xmlns='jabber:client'><body>x</body></message>",
|
||||
1767225600. + i as f64,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set' id='q'><query xmlns='urn:xmpp:mam:2' queryid='client'><set xmlns='http://jabber.org/protocol/rsm'><max>2</max></set></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert!(r[0].contains(&ids[0]));
|
||||
assert!(r[2].contains("complete='false'"));
|
||||
assert!(r[2].contains("<count>4</count>"));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set' id='q'><query xmlns='urn:xmpp:mam:2'><set xmlns='http://jabber.org/protocol/rsm'><max>1</max><before/></set></query></iq>",
|
||||
);
|
||||
assert!(r[0].contains(&ids[3]));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set' id='q'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='with'><value>bob@example.org</value></field></x></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert!(r[2].contains("<count>2</count>"));
|
||||
assert!(
|
||||
query(
|
||||
t.path(),
|
||||
"<iq type='set' to='bob@example.org'><query xmlns='urn:xmpp:mam:2'/></iq>"
|
||||
)[0]
|
||||
.contains("forbidden")
|
||||
);
|
||||
assert!(query(t.path(),"<iq type='set'><query xmlns='urn:xmpp:mam:2'><set xmlns='http://jabber.org/protocol/rsm'><after>missing</after></set></query></iq>")[0].contains("item-not-found"));
|
||||
}
|
||||
#[test]
|
||||
fn mam_preferences_persist() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><prefs xmlns='urn:xmpp:mam:2' default='never'><always><jid>bob@example.org</jid></always><never/></prefs></iq>",
|
||||
);
|
||||
assert!(r[0].contains("default='never'"));
|
||||
assert!(
|
||||
mam::should_archive(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"always"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!mam::should_archive(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"carol@example.org",
|
||||
"always"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let p = storage::load(t.path(), "example.org", Some("alice"), "archive_prefs")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
p.get_key(&storage::LuaValue::Bool(false)),
|
||||
Some(&storage::LuaValue::Bool(false))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn namespaces_expiry_and_concurrent_append() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let root = t.path().to_path_buf();
|
||||
std::thread::scope(|scope| {
|
||||
for i in 0..12 {
|
||||
let r = &root;
|
||||
scope.spawn(move||{archive::archive_message(r,"example.org","alice","bob@example.org","<message xmlns='jabber:client' xml:lang='ru'><body>test</body><x xmlns='urn:test'/></message>",1767225600.+i as f64).unwrap();});
|
||||
}
|
||||
});
|
||||
let rows = archive::load(&root, "example.org", "alice").unwrap();
|
||||
assert_eq!(rows.len(), 12);
|
||||
let doc = roxmltree::Document::parse(&rows[0].xml).unwrap();
|
||||
assert_eq!(
|
||||
doc.root_element()
|
||||
.attribute(("http://www.w3.org/XML/1998/namespace", "lang")),
|
||||
Some("ru")
|
||||
);
|
||||
assert_eq!(
|
||||
archive::expire_before(&root, "example.org", Some("alice"), "archive", 1767225606.)
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
assert_eq!(
|
||||
archive::load(&root, "example.org", "alice").unwrap().len(),
|
||||
6
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn zero_page_dates_and_invalid_forms() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
archive::archive_message(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"<message xmlns='jabber:client'><body>x</body></message>",
|
||||
1767225600.,
|
||||
)
|
||||
.unwrap();
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><set xmlns='http://jabber.org/protocol/rsm'><max>0</max></set></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0].contains("<count>1</count>"));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='start'><value>2026-01-02T00:00:00Z</value></field></x></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0].contains("<count>0</count>"));
|
||||
assert!(query(t.path(),"<iq type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='start'><value>2026-01-02T00:00:00Z</value></field></x></query></iq>")[0].contains("bad-request"));
|
||||
}
|
||||
#[test]
|
||||
fn append_is_incremental_and_recovers_torn_tail() {
|
||||
use std::io::Write;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list");
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
let original=b"item({\n name='message'; attr={xmlns='jabber:client'}; key='legacy'; when=1767225600; with='bob@example.org';\n {name='body';attr={};'original'}\n});\n";
|
||||
std::fs::write(&p, original).unwrap();
|
||||
#[cfg(unix)]
|
||||
let inode = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
std::fs::metadata(&p).unwrap().ino()
|
||||
};
|
||||
archive::archive_message(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"<message xmlns='jabber:client'><body>new</body></message>",
|
||||
1767225601.,
|
||||
)
|
||||
.unwrap();
|
||||
let intact = std::fs::read(&p).unwrap();
|
||||
assert!(intact.starts_with(original));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
assert_eq!(inode, std::fs::metadata(&p).unwrap().ino());
|
||||
}
|
||||
std::fs::write(
|
||||
p.with_extension("rappend"),
|
||||
(intact.len() as u64).to_le_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&p)
|
||||
.unwrap()
|
||||
.write_all(b"item({bad torn write")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
archive::load(t.path(), "example.org", "alice")
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(std::fs::read(&p).unwrap(), intact);
|
||||
assert!(!p.with_extension("rappend").exists());
|
||||
}
|
||||
#[test]
|
||||
fn smart_enable_and_message_selection() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
!mam::should_archive_with_options(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"always",
|
||||
true
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let doc =
|
||||
roxmltree::Document::parse("<iq type='get'><query xmlns='urn:xmpp:mam:2'/></iq>").unwrap();
|
||||
mam::handle_with_options(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice@example.org/r",
|
||||
doc.root_element(),
|
||||
"always",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
mam::should_archive_with_options(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"always",
|
||||
true
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (xml, expected) in [
|
||||
("<message><body>x</body></message>", true),
|
||||
(
|
||||
"<message><received xmlns='urn:xmpp:receipts'/></message>",
|
||||
true,
|
||||
),
|
||||
("<message type='headline'><body>x</body></message>", false),
|
||||
(
|
||||
"<message><body>x</body><no-permanent-store xmlns='urn:xmpp:hints'/></message>",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"<message><active xmlns='http://jabber.org/protocol/chatstates'/></message>",
|
||||
false,
|
||||
),
|
||||
("<message type='error' to='alice@example.org/r'/>", true),
|
||||
] {
|
||||
let d = roxmltree::Document::parse(xml).unwrap();
|
||||
assert_eq!(mam::should_store(d.root_element()), expected, "{xml}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "large-file scalability regression; run explicitly"]
|
||||
fn archive_larger_than_128mib_streams() {
|
||||
use std::io::Write;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list");
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
let mut f = std::fs::File::create(&p).unwrap();
|
||||
let padding = vec![b' '; 1024 * 1024];
|
||||
for i in 0..130 {
|
||||
writeln!(f,"item({{name='message';attr={{xmlns='jabber:client'}};key='{i}';when=1767225600;with='bob@example.org';{{name='body';attr={{}};'x'}}}});").unwrap();
|
||||
f.write_all(&padding).unwrap();
|
||||
f.write_all(b"\n").unwrap();
|
||||
}
|
||||
drop(f);
|
||||
assert!(std::fs::metadata(&p).unwrap().len() > 128 * 1024 * 1024);
|
||||
let mut count = 0;
|
||||
archive::scan_store(t.path(), "example.org", Some("alice"), "archive", |_| {
|
||||
count += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 130);
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><set xmlns='http://jabber.org/protocol/rsm'><max>0</max></set></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0].contains("<count>130</count>"));
|
||||
}
|
||||
#[test]
|
||||
fn extended_ids_ranges_flip_and_metadata() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
for i in 1..=8 {
|
||||
archive::append_new(
|
||||
t.path(),
|
||||
"example.org",
|
||||
Some("alice"),
|
||||
"archive",
|
||||
archive::Entry {
|
||||
id: format!("id{i}"),
|
||||
when: 1767225600. + i as f64,
|
||||
peer: "bob@example.org".into(),
|
||||
xml: "<message xmlns='jabber:client'><body>x</body></message>".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='get'><metadata xmlns='urn:xmpp:mam:2'/></iq>",
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&r[0]).unwrap();
|
||||
assert_eq!(
|
||||
doc.descendants()
|
||||
.find(|n| n.has_tag_name((mam::NS, "start")))
|
||||
.unwrap()
|
||||
.attribute("id"),
|
||||
Some("id1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc.descendants()
|
||||
.find(|n| n.has_tag_name((mam::NS, "end")))
|
||||
.unwrap()
|
||||
.attribute("id"),
|
||||
Some("id8")
|
||||
);
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='before-id'><value>id7</value></field><field var='after-id'><value>id2</value></field></x><set xmlns='http://jabber.org/protocol/rsm'><max>1</max></set></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert!(r[0].contains("id='id3'"));
|
||||
assert!(r[1].contains("complete='false'"));
|
||||
assert!(!r[1].contains("<count>"));
|
||||
assert!(!r[1].contains("index="));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='ids'><value>id2</value><value>id5</value><value>id2</value></field></x><flip-page/></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert!(r[0].contains("id='id5'"));
|
||||
assert!(r[1].contains("id='id2'"));
|
||||
assert!(r[2].contains(">id2</first><last>id5</last>"));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><set xmlns='http://jabber.org/protocol/rsm'><max>2</max><before>id6</before></set><flip-page/></query></iq>",
|
||||
);
|
||||
assert!(r[0].contains("id='id5'"));
|
||||
assert!(r[1].contains("id='id4'"));
|
||||
assert!(r[2].contains(">id4</first><last>id5</last>"));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='get'><query xmlns='urn:xmpp:mam:2'/></iq>",
|
||||
);
|
||||
assert!(r[0].contains("var='before-id'"));
|
||||
assert!(r[0].contains("var='ids'"));
|
||||
}
|
||||
#[test]
|
||||
fn real_archive_sid_and_sender_privacy() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let input = "<message xmlns='jabber:client' from='alice@example.org/r' to='bob@example.org'><body>test</body><active xmlns='http://jabber.org/protocol/chatstates'/><origin-id xmlns='urn:xmpp:sid:0' id='origin'/><stanza-id xmlns='urn:xmpp:sid:0' by='ALICE@EXAMPLE.ORG' id='forged'/><stanza-id xmlns='urn:xmpp:sid:0' by='remote.example' id='remote'/></message>";
|
||||
let (id, delivery) = mam::archive_for_user(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org/r",
|
||||
input,
|
||||
1767225600.,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!delivery.contains("forged"));
|
||||
assert!(delivery.contains("remote"));
|
||||
assert!(delivery.contains("origin"));
|
||||
assert!(delivery.contains(&id));
|
||||
assert!(delivery.contains("chatstates"));
|
||||
let saved = archive::load(t.path(), "example.org", "alice").unwrap();
|
||||
assert_eq!(saved[0].id, id);
|
||||
assert_eq!(saved[0].peer, "bob@example.org");
|
||||
assert!(!saved[0].xml.contains(&id));
|
||||
assert!(!saved[0].xml.contains("chatstates"));
|
||||
let stripped = mam::strip_owner_stanza_ids(&delivery, "example.org", "alice").unwrap();
|
||||
assert!(!stripped.contains(&id));
|
||||
assert!(stripped.contains("remote"));
|
||||
assert!(stripped.contains("origin"));
|
||||
}
|
||||
#[test]
|
||||
fn equal_cursors_and_normalized_query_jids() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
for i in 1..=3 {
|
||||
archive::append_new(
|
||||
t.path(),
|
||||
"example.org",
|
||||
Some("alice"),
|
||||
"archive",
|
||||
archive::Entry {
|
||||
id: format!("id{i}"),
|
||||
when: 1767225600. + i as f64,
|
||||
peer: "bob@example.org".into(),
|
||||
xml: "<message xmlns='jabber:client'><body>x</body></message>".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><set xmlns='http://jabber.org/protocol/rsm'><before>id2</before><after>id2</after></set></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0].contains("type='result'"));
|
||||
assert!(r[0].contains("complete='true'"));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='with'><value>BOB@EXAMPLE.ORG/phone</value></field></x></query></iq>",
|
||||
);
|
||||
assert_eq!(r.len(), 4);
|
||||
assert!(r[3].contains("<count>3</count>"));
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='with'><value>bob@example.org</value><value>carol@example.org</value></field></x></query></iq>",
|
||||
);
|
||||
assert!(r[0].contains("bad-request"));
|
||||
assert!(r[0].contains("type='modify'"));
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_archive_reports_server_error_and_namespace_stripping_is_precise() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let p = storage::path(t.path(), "example.org", Some("alice"), "archive").with_extension("list");
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, b"item({ broken").unwrap();
|
||||
let r = query(
|
||||
t.path(),
|
||||
"<iq type='set'><query xmlns='urn:xmpp:mam:2'/></iq>",
|
||||
);
|
||||
assert!(r[0].contains("internal-server-error"));
|
||||
assert!(!r[0].contains("bad-request"));
|
||||
std::fs::remove_file(p).unwrap();
|
||||
let input = "<message xmlns='jabber:client'><body>x</body><active xmlns='http://jabber.org/protocol/chatstates'/><private xmlns='urn:custom:private'/><stanza-id xmlns='urn:unrelated' by='alice@example.org' id='unrelated'/><stanza-id xmlns='urn:xmpp:sid:0' by='alice@example.org/r' id='resource'/><stanza-id xmlns='urn:xmpp:sid:0' by='alice@example.org' id='fake'/></message>";
|
||||
let (id, delivery) = mam::archive_for_user_with_namespaces(
|
||||
t.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
input,
|
||||
1767225600.,
|
||||
&["urn:custom:private"],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(delivery.contains("unrelated"));
|
||||
assert!(delivery.contains("resource"));
|
||||
assert!(!delivery.contains("id='fake'"));
|
||||
assert!(delivery.contains("urn:custom:private"));
|
||||
let stored = archive::load(t.path(), "example.org", "alice").unwrap();
|
||||
assert!(stored[0].xml.contains("chatstates"));
|
||||
assert!(!stored[0].xml.contains("urn:custom:private"));
|
||||
assert!(!stored[0].xml.contains(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muc_archive_filters_stanza_kind_instead_of_sender_jid() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
for (id, peer) in [
|
||||
("group", "message<groupchat"),
|
||||
("presence", "presence<available"),
|
||||
] {
|
||||
archive::append_new(
|
||||
t.path(),
|
||||
"rooms.example.org",
|
||||
Some("room"),
|
||||
"muc_log",
|
||||
archive::Entry {
|
||||
id: id.into(),
|
||||
peer: peer.into(),
|
||||
when: 1767225600.,
|
||||
xml: "<message xmlns='jabber:client' type='groupchat'><body>hello</body></message>"
|
||||
.into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for value in ["", "alice@example.org", "message<groupchat"] {
|
||||
let xml = format!(
|
||||
"<iq type='set' id='q'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='with'><value>{value}</value></field></x></query></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
let iq = doc.root_element();
|
||||
let q = iq.children().find(|n| n.is_element()).unwrap();
|
||||
let replies = mam::handle_archive_query(
|
||||
t.path(),
|
||||
"rooms.example.org",
|
||||
Some("room"),
|
||||
"muc_log",
|
||||
"room@rooms.example.org",
|
||||
iq,
|
||||
q,
|
||||
)
|
||||
.unwrap()
|
||||
.replies;
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert!(replies[0].contains("id='group'") || replies[0].contains("id=\"group\""));
|
||||
assert!(!replies[0].contains("id='presence'"));
|
||||
assert!(replies[1].contains("<count>1</count>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_roundtrip_preserves_default_namespace_resets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let id = archive::archive_message(
|
||||
temp.path(),
|
||||
"example.org",
|
||||
"alice",
|
||||
"bob@example.org",
|
||||
"<message xmlns='jabber:client'><body>saved</body><payload xmlns=''><plain/><nested xmlns='urn:test'><empty xmlns=''/></nested></payload></message>",
|
||||
1767225600.,
|
||||
).unwrap();
|
||||
// Retention rewrites records, so also check that namespace resets survive it.
|
||||
archive::append_new(
|
||||
temp.path(),
|
||||
"example.org",
|
||||
Some("alice"),
|
||||
"archive",
|
||||
archive::Entry {
|
||||
id: "old".into(),
|
||||
when: 1767225500.,
|
||||
peer: "bob@example.org".into(),
|
||||
xml: "<message xmlns='jabber:client'><body>old</body></message>".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
archive::expire_before(
|
||||
temp.path(),
|
||||
"example.org",
|
||||
Some("alice"),
|
||||
"archive",
|
||||
1767225550.
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let rows = archive::load(temp.path(), "example.org", "alice").unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, id);
|
||||
let doc = roxmltree::Document::parse(&rows[0].xml).unwrap();
|
||||
for name in ["payload", "plain", "empty"] {
|
||||
let node = doc
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == name)
|
||||
.unwrap();
|
||||
assert_eq!(node.tag_name().namespace(), Some(""), "{name}");
|
||||
}
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:test", "nested")))
|
||||
);
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.has_tag_name(("jabber:client", "body")))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use rust_xmpp::auth;
|
||||
#[test]
|
||||
fn create_and_read_hashed_accounts() {
|
||||
for hash in ["SHA-1", "SHA-256"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
auth::create(dir.path(), "test.local", "alice", "pässword", hash).unwrap();
|
||||
assert!(auth::verify(dir.path(), "test.local", "alice", "pässword", hash).unwrap());
|
||||
assert!(!auth::verify(dir.path(), "test.local", "alice", "incorrect", hash).unwrap());
|
||||
assert!(!auth::verify(dir.path(), "test.local", "missing", "pässword", hash).unwrap());
|
||||
assert!(auth::create(dir.path(), "test.local", "alice", "replacement", hash).is_err());
|
||||
assert!(auth::verify(dir.path(), "test.local", "alice", "pässword", hash).unwrap());
|
||||
let path = dir.path().join("test%2elocal/accounts/alice.dat");
|
||||
let bytes = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!bytes.contains("pässword"));
|
||||
std::fs::write(
|
||||
&path,
|
||||
bytes.replace("return {", "return { disabled = true;"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!auth::verify(dir.path(), "test.local", "alice", "pässword", hash).unwrap());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn traversal_and_malicious_account_records_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(auth::create(dir.path(), "test.local", "../escape", "x", "SHA-1").is_err());
|
||||
auth::create(dir.path(), "test.local", "alice", "x", "SHA-1").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("test%2elocal/accounts/alice.dat"),
|
||||
"return os.execute('false')",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(auth::verify(dir.path(), "test.local", "alice", "x", "SHA-1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_prepares_names_without_relaxing_account_management() {
|
||||
for hash in ["SHA-1", "SHA-256"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
auth::create(dir.path(), "test.local", "alice", "secret", hash).unwrap();
|
||||
for supplied in ["ALICE", "Alice", "al\u{00ad}ice"] {
|
||||
assert_eq!(auth::prepared_auth_username(supplied).unwrap(), "alice");
|
||||
assert!(auth::verify(dir.path(), "test.local", supplied, "secret", hash).unwrap());
|
||||
assert!(!auth::verify(dir.path(), "test.local", supplied, "wrong", hash).unwrap());
|
||||
assert!(
|
||||
auth::load_scram(dir.path(), "test.local", supplied, hash)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(auth::create(dir.path(), "test.local", supplied, "secret", hash).is_err());
|
||||
}
|
||||
for invalid in ["", "alice@example.com", "../escape", "alice\u{0007}"] {
|
||||
assert!(auth::prepared_auth_username(invalid).is_err());
|
||||
assert!(!auth::verify(dir.path(), "test.local", invalid, "secret", hash).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::process::Command;
|
||||
fn command() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_rust-xmpp"))
|
||||
}
|
||||
#[test]
|
||||
fn conversion_preserves_source_and_refuses_overwrite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = dir.path().join("config.lua");
|
||||
let output = dir.path().join("config.yaml");
|
||||
let input = "VirtualHost 'test.local'\npassword='TEST-SECRET'";
|
||||
std::fs::write(&source, input).unwrap();
|
||||
let result = command()
|
||||
.args([
|
||||
"convert",
|
||||
source.to_str().unwrap(),
|
||||
"yaml",
|
||||
output.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success());
|
||||
assert!(!String::from_utf8_lossy(&result.stdout).contains("TEST-SECRET"));
|
||||
assert!(
|
||||
std::fs::read_to_string(&output)
|
||||
.unwrap()
|
||||
.contains("TEST-SECRET")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&source).unwrap(), input);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&output).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
let bytes = std::fs::read(&output).unwrap();
|
||||
assert!(
|
||||
!command()
|
||||
.args([
|
||||
"convert",
|
||||
source.to_str().unwrap(),
|
||||
"yaml",
|
||||
output.to_str().unwrap()
|
||||
])
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(std::fs::read(&output).unwrap(), bytes);
|
||||
}
|
||||
#[test]
|
||||
fn server_cannot_be_mistaken_for_running_service() {
|
||||
let output = command().arg("serve").output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("not implemented"));
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
use rust_xmpp::config::{self, Config};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
fn lua(text: &str) -> anyhow::Result<Config> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("config.lua");
|
||||
std::fs::write(&path, text)?;
|
||||
config::load(&path)
|
||||
}
|
||||
#[test]
|
||||
fn supplied_configuration_scopes_arithmetic_and_modules() {
|
||||
let cfg = config::load(Path::new("tests/fixtures/prosody.cfg.lua")).unwrap();
|
||||
assert_eq!(cfg.hosts.len(), 4);
|
||||
assert_eq!(
|
||||
cfg.option("wthome.ru", "authentication"),
|
||||
Some(&json!("internal_hashed"))
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.option("upload.wthome.ru", "http_file_share_size_limit"),
|
||||
Some(&json!(10737418240_u64))
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.option("upload.wthome.ru", "http_file_share_global_quota"),
|
||||
Some(&json!(107374182400_u64))
|
||||
);
|
||||
assert!(cfg.modules("wthome.ru").unwrap().contains("sasl2"));
|
||||
assert_eq!(
|
||||
cfg.modules("conference.wthome.ru")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["muc", "muc_mam"]
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.modules("upload.wthome.ru")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["http_file_share"]
|
||||
);
|
||||
assert!(
|
||||
cfg.diagnostics()
|
||||
.iter()
|
||||
.any(|d| d.message.contains("security_labels"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn all_formats_round_trip_actual_config() {
|
||||
let c = config::load(Path::new("tests/fixtures/prosody.cfg.lua")).unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (ext, text) in [
|
||||
("json", serde_json::to_string(&c).unwrap()),
|
||||
("yaml", serde_yaml_ng::to_string(&c).unwrap()),
|
||||
("toml", toml::to_string(&c).unwrap()),
|
||||
] {
|
||||
let p = dir.path().join(format!("c.{ext}"));
|
||||
std::fs::write(&p, text).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&c).unwrap(),
|
||||
serde_json::to_value(config::load(&p).unwrap()).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn include_has_own_global_scope_and_parent_scope_is_restored() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("main.lua"),
|
||||
"VirtualHost 'one.test'\nInclude 'child.lua'\nname='one'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("child.lua"),
|
||||
"archive_expires_after='never'\nVirtualHost 'two.test'\nname='two'",
|
||||
)
|
||||
.unwrap();
|
||||
let c = config::load(&dir.path().join("main.lua")).unwrap();
|
||||
assert_eq!(c.global["archive_expires_after"], json!("never"));
|
||||
assert_eq!(c.hosts["one.test"].options["name"], json!("one"));
|
||||
assert_eq!(c.hosts["two.test"].options["name"], json!("two"));
|
||||
}
|
||||
#[test]
|
||||
fn include_cycles_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("c.lua");
|
||||
std::fs::write(&p, "Include 'c.lua'").unwrap();
|
||||
assert!(config::load(&p).unwrap_err().to_string().contains("cycle"));
|
||||
}
|
||||
#[test]
|
||||
fn declarations_and_overrides() {
|
||||
let c=lua("modules_enabled={'roster','mam'}\nVirtualHost 'ONE.TEST' {modules_disabled={'mam'}}\nComponent 'muc.test' 'muc' {name='Rooms'}\nallow_registration=false").unwrap();
|
||||
assert_eq!(
|
||||
c.modules("one.test")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["roster"]
|
||||
);
|
||||
assert_eq!(c.global["allow_registration"], json!(false));
|
||||
assert_eq!(c.hosts["muc.test"].component.as_deref(), Some("muc"));
|
||||
}
|
||||
#[test]
|
||||
fn runtime_capabilities_are_unavailable() {
|
||||
for source in [
|
||||
"os.execute('true')",
|
||||
"io.open('/etc/passwd')",
|
||||
"require('socket')",
|
||||
"RunScript 'x'",
|
||||
"Lua.os.execute('true')",
|
||||
] {
|
||||
assert!(lua(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn loops_are_bounded() {
|
||||
assert!(
|
||||
lua("while true do end")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("budget")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_values_rejected() {
|
||||
for text in [
|
||||
"VirtualHost 'x.test'\nc2s_ports={0}",
|
||||
"VirtualHost 'x.test'\nc2s_ports={65536}",
|
||||
"VirtualHost 'x.test'\nc2s_ports={5222,5222}",
|
||||
"VirtualHost 'x.test'\nmodules_enabled='mam'",
|
||||
"VirtualHost 'x.test'\nallow_registration='false'",
|
||||
"VirtualHost 'x.test'\nComponent 'x.test' 'muc'",
|
||||
"VirtualHost '../x'",
|
||||
"VirtualHost 'x.test'\nx={ [2]='hole' }",
|
||||
"VirtualHost 'x.test'\nx={ 'item', key='value' }",
|
||||
] {
|
||||
assert!(lua(text).is_err(), "accepted {text}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn tables_cannot_recurse_forever() {
|
||||
assert!(lua("local t={}\nt.self=t\nVirtualHost 'x.test'\nx=t").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn component_can_explicitly_enable_global_modules() {
|
||||
let c = lua("modules_enabled={'ping'}\nComponent 'muc.test' 'muc'\nload_global_modules=true")
|
||||
.unwrap();
|
||||
assert!(c.modules("muc.test").unwrap().contains("ping"));
|
||||
}
|
||||
#[test]
|
||||
fn storage_paths_encode_like_prosody() {
|
||||
use rust_xmpp::storage::encode_name;
|
||||
assert_eq!(encode_name("wthome.ru"), "wthome%2eru");
|
||||
assert_eq!(encode_name("../x_y"), "%2e%2e%2fx%5fy");
|
||||
assert_eq!(encode_name("я"), "%d1%8f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplied_config_matches_original_prosody_loader() {
|
||||
// The original loader is run only as a reference in tests, with stubs for
|
||||
// helpers unused by this fixture. This is not a runtime dependency.
|
||||
let vm = mlua::Lua::new();
|
||||
vm.load(
|
||||
r#"
|
||||
package.preload['prosody.util.envload'] = function()
|
||||
return { envload=function(s,n,e) return load(s,n,'t',e) end }
|
||||
end
|
||||
package.preload['prosody.util.dependencies'] = function()
|
||||
return { softreq=function() return nil end }
|
||||
end
|
||||
package.preload['prosody.util.iterators'] = function() return {} end
|
||||
package.preload['prosody.util.paths'] = function()
|
||||
return { resolve_relative_path=function(p,f) return p..f end }
|
||||
end
|
||||
package.preload['prosody.util.debug'] = function()
|
||||
return { get_traceback_table=function() return {} end }
|
||||
end
|
||||
package.preload['prosody.util.error'] = function()
|
||||
return { is_error=function() return false end }
|
||||
end
|
||||
package.preload['prosody.util.logger'] = function()
|
||||
return { init=function() return function() end end }
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.exec()
|
||||
.unwrap();
|
||||
let reference: mlua::Table = vm
|
||||
.load(include_str!("reference/configmanager.lua"))
|
||||
.eval()
|
||||
.unwrap();
|
||||
let load: mlua::Function = reference.get("load").unwrap();
|
||||
let (ok, _, _): (bool, mlua::Value, mlua::Value) = load
|
||||
.call(("tests/fixtures/prosody.cfg.lua", "lua"))
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
let get: mlua::Function = reference.get("getconfig").unwrap();
|
||||
let table: mlua::Table = get.call(()).unwrap();
|
||||
let parsed = config::load(Path::new("tests/fixtures/prosody.cfg.lua")).unwrap();
|
||||
let global: mlua::Value = table.get("*").unwrap();
|
||||
assert_eq!(
|
||||
rust_xmpp::lua::json_value(global, 0).unwrap(),
|
||||
json!(parsed.global)
|
||||
);
|
||||
for (name, host) in &parsed.hosts {
|
||||
let v: mlua::Value = table.get(name.as_str()).unwrap();
|
||||
let mut reference = rust_xmpp::lua::json_value(v, 0).unwrap();
|
||||
let object = reference.as_object_mut().unwrap();
|
||||
object.remove("defined");
|
||||
let component = object.remove("component_module");
|
||||
assert_eq!(component, host.component.as_ref().map(|v| json!(v)));
|
||||
assert_eq!(reference, json!(host.options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use rust_xmpp::csi::{Buffer, important};
|
||||
use std::time::{Duration, Instant};
|
||||
#[test]
|
||||
fn native_importance_rules() {
|
||||
for xml in [
|
||||
"<presence/>",
|
||||
"<presence type='unavailable'/>",
|
||||
"<presence type='error'/>",
|
||||
"<message><active xmlns='http://jabber.org/protocol/chatstates'/></message>",
|
||||
"<message type='headline'><body>news</body></message>",
|
||||
] {
|
||||
assert!(!important(xml, &[]), "{xml}");
|
||||
}
|
||||
for xml in [
|
||||
" ",
|
||||
"<iq/>",
|
||||
"<r xmlns='urn:xmpp:sm:3'/>",
|
||||
"<presence type='subscribe'/>",
|
||||
"<message><body/></message>",
|
||||
"<message><subject/></message>",
|
||||
"<message type='error'/>",
|
||||
"<message><encryption xmlns='urn:xmpp:eme:0'/></message>",
|
||||
"<message><propose xmlns='urn:xmpp:jingle-message:1'/></message>",
|
||||
"<message><sent xmlns='urn:xmpp:carbons:2'/></message>",
|
||||
"<message><received xmlns='urn:xmpp:carbons:2'><forwarded xmlns='urn:xmpp:forward:0'><message xmlns='jabber:client'><body>hey</body></message></forwarded></received></message>",
|
||||
] {
|
||||
assert!(important(xml, &[]), "{xml}");
|
||||
}
|
||||
assert!(important(
|
||||
"<message><custom xmlns='example:ns'><part/></custom></message>",
|
||||
&["{example:ns}custom/part".into()]
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn bounded_queue_flush_delay_activity_and_grace() {
|
||||
let mut b = Buffer::default();
|
||||
b.configure(2, Duration::from_secs(5), vec![]);
|
||||
b.set_inactive(true);
|
||||
let now = Instant::now();
|
||||
assert!(b.output("<presence/>".into(), "a@example", now).is_empty());
|
||||
assert!(b.output("<message/>".into(), "a@example", now).is_empty());
|
||||
let output = b.output("<presence/>".into(), "a@example", now);
|
||||
assert_eq!(output.len(), 3);
|
||||
assert!(output[0].contains("urn:xmpp:delay"));
|
||||
assert!(output[0].contains("a@example"));
|
||||
assert_eq!(b.output("<presence/>".into(), "a@example", now).len(), 1);
|
||||
assert!(
|
||||
b.output(
|
||||
"<presence/>".into(),
|
||||
"a@example",
|
||||
now + Duration::from_secs(6)
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(b.activity(now + Duration::from_secs(6)).len(), 1);
|
||||
b.set_inactive(true);
|
||||
b.output("<presence/>".into(), "a@example", now);
|
||||
assert_eq!(b.set_inactive(false).len(), 1);
|
||||
b.set_inactive(true);
|
||||
b.output("<presence/>".into(), "a@example", now);
|
||||
b.reset();
|
||||
assert!(b.activity(now).is_empty());
|
||||
assert_eq!(b.output("<presence/>".into(), "a@example", now).len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Compare importance decisions with the supplied, unmodified mod_csi_simple.
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn native_prosody_csi_importance_matches() {
|
||||
let cases = [
|
||||
("<presence/>", "st.presence()"),
|
||||
(
|
||||
"<presence type='unavailable'/>",
|
||||
"st.presence({type='unavailable'})",
|
||||
),
|
||||
(
|
||||
"<presence type='subscribe'/>",
|
||||
"st.presence({type='subscribe'})",
|
||||
),
|
||||
(
|
||||
"<iq type='result' id='a'/>",
|
||||
"st.iq({type='result',id='a'})",
|
||||
),
|
||||
("<message><body>hi</body></message>", "st.message({},'hi')"),
|
||||
(
|
||||
"<message type='headline'><body>hi</body></message>",
|
||||
"st.message({type='headline'},'hi')",
|
||||
),
|
||||
(
|
||||
"<message><composing xmlns='http://jabber.org/protocol/chatstates'/></message>",
|
||||
"st.message():tag('composing',{xmlns='http://jabber.org/protocol/chatstates'}):up()",
|
||||
),
|
||||
(
|
||||
"<message><encryption xmlns='urn:xmpp:eme:0'/></message>",
|
||||
"st.message():tag('encryption',{xmlns='urn:xmpp:eme:0'}):up()",
|
||||
),
|
||||
(
|
||||
"<message><encrypted xmlns='eu.siacs.conversations.axolotl'/></message>",
|
||||
"st.message():tag('encrypted',{xmlns='eu.siacs.conversations.axolotl'}):up()",
|
||||
),
|
||||
(
|
||||
"<message><sent xmlns='urn:xmpp:carbons:2'/></message>",
|
||||
"st.message():tag('sent',{xmlns='urn:xmpp:carbons:2'}):up()",
|
||||
),
|
||||
(
|
||||
"<message><received xmlns='urn:xmpp:carbons:2'><forwarded xmlns='urn:xmpp:forward:0'><message xmlns='jabber:client'><body>hi</body></message></forwarded></received></message>",
|
||||
"st.message():tag('received',{xmlns='urn:xmpp:carbons:2'}):tag('forwarded',{xmlns='urn:xmpp:forward:0'}):add_child(st.message({xmlns='jabber:client'},'hi')):reset()",
|
||||
),
|
||||
(
|
||||
"<message><propose xmlns='urn:xmpp:jingle-message:0'/></message>",
|
||||
"st.message():tag('propose',{xmlns='urn:xmpp:jingle-message:0'}):up()",
|
||||
),
|
||||
(
|
||||
"<message><x xmlns='http://jabber.org/protocol/muc#user'><invite/></x></message>",
|
||||
"st.message():tag('x',{xmlns='http://jabber.org/protocol/muc#user'}):tag('invite'):reset()",
|
||||
),
|
||||
(
|
||||
"<message><payload xmlns='urn:test'/></message>",
|
||||
"st.message():tag('payload',{xmlns='urn:test'}):up()",
|
||||
),
|
||||
(
|
||||
"<r xmlns='urn:xmpp:sm:3'/>",
|
||||
"st.stanza('r',{xmlns='urn:xmpp:sm:3'})",
|
||||
),
|
||||
];
|
||||
let bootstrap = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/prosody_compat/oracle.lua"
|
||||
))
|
||||
.unwrap();
|
||||
let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap();
|
||||
let mut script = format!(
|
||||
"{bootstrap}\nconfig.set('example.org','csi_important_payloads',{{'{{urn:test}}payload'}});\nassert(mm.load('example.org','csi_simple'));\nlocal csi=assert(mm.get_module('example.org','csi_simple'));\n"
|
||||
);
|
||||
for (index, (_, expression)) in cases.iter().enumerate() {
|
||||
script.push_str(&format!(
|
||||
"print('CSI_CASE_{index}='..tostring(not not csi.is_important({expression})));\n"
|
||||
));
|
||||
}
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let path = folder.path().join("csi.lua");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
let output = std::process::Command::new(
|
||||
std::env::var("PROSODY_REFERENCE_LUA").unwrap_or_else(|_| "lua5.4".into()),
|
||||
)
|
||||
.arg(path)
|
||||
.arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap())
|
||||
.arg(folder.path())
|
||||
.arg("csi")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
for (index, (xml, _)) in cases.iter().enumerate() {
|
||||
let expected = format!(
|
||||
"CSI_CASE_{index}={}",
|
||||
rust_xmpp::csi::important(xml, &["{urn:test}payload".into()])
|
||||
);
|
||||
assert!(
|
||||
stdout.lines().any(|line| line == expected),
|
||||
"{xml}: expected {expected}, native output {stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use rust_xmpp::data_lock::DataDirectoryGuard;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn child_process_probe() {
|
||||
let Some(root) = std::env::var_os("XMPP_LOCK_TEST_DIRECTORY") else {
|
||||
return;
|
||||
};
|
||||
let acquired = DataDirectoryGuard::acquire(std::path::Path::new(&root)).is_ok();
|
||||
assert_eq!(
|
||||
acquired,
|
||||
std::env::var("XMPP_LOCK_TEST_AVAILABLE").unwrap() == "yes"
|
||||
);
|
||||
}
|
||||
|
||||
fn probe(root: &std::path::Path, available: bool) {
|
||||
let output = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "child_process_probe", "--nocapture"])
|
||||
.env("XMPP_LOCK_TEST_DIRECTORY", root)
|
||||
.env(
|
||||
"XMPP_LOCK_TEST_AVAILABLE",
|
||||
if available { "yes" } else { "no" },
|
||||
)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_another_process_and_releases_on_drop() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let native = root.path().join("existing-prosody-data");
|
||||
std::fs::write(&native, b"unchanged").unwrap();
|
||||
let guard = DataDirectoryGuard::acquire(root.path()).unwrap();
|
||||
probe(root.path(), false);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let aliases = tempfile::tempdir().unwrap();
|
||||
let alias = aliases.path().join("same-volume");
|
||||
std::os::unix::fs::symlink(root.path(), &alias).unwrap();
|
||||
probe(&alias, false);
|
||||
}
|
||||
drop(guard);
|
||||
probe(root.path(), true);
|
||||
assert_eq!(std::fs::read(&native).unwrap(), b"unchanged");
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#[test]
|
||||
fn debug_empty_legacy_bind() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
rust_xmpp::auth::create(t.path(), "localhost", "alice", "secret", "SHA-256").unwrap();
|
||||
eprintln!("before legacy");
|
||||
let r =
|
||||
rust_xmpp::account_modules::legacy_on_bind(t.path(), "localhost", "alice@localhost/main")
|
||||
.unwrap();
|
||||
eprintln!("after legacy {} {}", r.replies.len(), r.deliveries.len());
|
||||
let d = roxmltree::Document::parse("<presence xmlns='jabber:client'/>").unwrap();
|
||||
eprintln!("before avatar");
|
||||
let _ = rust_xmpp::account_modules::avatar_presence(
|
||||
t.path(),
|
||||
"localhost",
|
||||
"alice@localhost/main",
|
||||
d.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
eprintln!("after avatar");
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
use rust_xmpp::federation::Federation;
|
||||
use std::{fs, io::BufReader, path::Path, process::Command};
|
||||
use tokio::{net::TcpListener, sync::mpsc};
|
||||
use tokio_rustls::rustls::{
|
||||
RootCertStore,
|
||||
pki_types::{CertificateDer, PrivateKeyDer},
|
||||
};
|
||||
fn openssl(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("openssl")
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
fn fixture(dir: &Path) {
|
||||
openssl(
|
||||
dir,
|
||||
&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Federation Test CA",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:TRUE",
|
||||
],
|
||||
);
|
||||
for domain in ["alpha.test", "beta.test"] {
|
||||
let key = format!("{domain}.key");
|
||||
let csr = format!("{domain}.csr");
|
||||
let cert = format!("{domain}.pem");
|
||||
let ext = format!("{domain}.ext");
|
||||
fs::write(dir.join(&ext),format!("basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:{domain}\n")).unwrap();
|
||||
openssl(
|
||||
dir,
|
||||
&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
&key,
|
||||
"-out",
|
||||
&csr,
|
||||
"-subj",
|
||||
&format!("/CN={domain}"),
|
||||
],
|
||||
);
|
||||
openssl(
|
||||
dir,
|
||||
&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
&csr,
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
&cert,
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
&ext,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
fn material(
|
||||
dir: &Path,
|
||||
domain: &str,
|
||||
) -> (
|
||||
Vec<CertificateDer<'static>>,
|
||||
PrivateKeyDer<'static>,
|
||||
RootCertStore,
|
||||
) {
|
||||
let certs = rustls_pemfile::certs(&mut BufReader::new(
|
||||
fs::File::open(dir.join(format!("{domain}.pem"))).unwrap(),
|
||||
))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
let key = rustls_pemfile::private_key(&mut BufReader::new(
|
||||
fs::File::open(dir.join(format!("{domain}.key"))).unwrap(),
|
||||
))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut roots = RootCertStore::empty();
|
||||
for cert in rustls_pemfile::certs(&mut BufReader::new(
|
||||
fs::File::open(dir.join("ca.pem")).unwrap(),
|
||||
)) {
|
||||
roots.add(cert.unwrap()).unwrap();
|
||||
}
|
||||
(certs, key, roots)
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mutual_tls_external_delivery_and_certificate_identity_rejection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fixture(temp.path());
|
||||
let (a_cert, a_key, a_roots) = material(temp.path(), "alpha.test");
|
||||
let mut alice = Federation::new("alpha.test".into(), a_cert, a_key, a_roots).unwrap();
|
||||
let (b_cert, b_key, b_roots) = material(temp.path(), "beta.test");
|
||||
let bob = Federation::new("beta.test".into(), b_cert, b_key, b_roots).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
alice
|
||||
.set_target("beta.test".into(), "127.0.0.1".into(), port)
|
||||
.unwrap();
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let bob = bob.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = bob.accept(tcp, tx).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
let xml = "<message xmlns='jabber:client' from='alice@alpha.test/r' to='bob@beta.test' type='chat'><body>Hello</body><encrypted xmlns='urn:xmpp:omemo:2'>opaque</encrypted></message>";
|
||||
alice
|
||||
.route("alpha.test", "bob@beta.test", xml)
|
||||
.await
|
||||
.unwrap();
|
||||
let inbound = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(inbound.from_domain, "alpha.test");
|
||||
assert!(inbound.xml.contains("jabber:server"));
|
||||
assert!(inbound.xml.contains("urn:xmpp:omemo:2"));
|
||||
// An operator routing override changes only the TCP endpoint: beta's trusted
|
||||
// certificate must never authenticate it as gamma.test.
|
||||
alice
|
||||
.set_target("gamma.test".into(), "127.0.0.1".into(), port)
|
||||
.unwrap();
|
||||
assert!(
|
||||
alice
|
||||
.route(
|
||||
"alpha.test",
|
||||
"bob@gamma.test",
|
||||
"<message from='alice@alpha.test' to='bob@gamma.test'/>",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
// Domain matching, target lookup and certificate authentication use the same
|
||||
// case-insensitive DNS identity.
|
||||
alice
|
||||
.route(
|
||||
"ALPHA.TEST",
|
||||
"bob@BETA.TEST",
|
||||
"<message from='alice@ALPHA.TEST/r' to='bob@BETA.TEST' id='mixed-case'/>",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let received = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(received.xml.contains("mixed-case"));
|
||||
// Identity matching cannot compensate for an untrusted certificate chain.
|
||||
let (certs, key, _) = material(temp.path(), "alpha.test");
|
||||
let mut unrelated_roots = RootCertStore::empty();
|
||||
unrelated_roots.add(certs[0].clone()).unwrap();
|
||||
let mut untrusted = Federation::new("alpha.test".into(), certs, key, unrelated_roots).unwrap();
|
||||
untrusted
|
||||
.set_target("beta.test".into(), "127.0.0.1".into(), port)
|
||||
.unwrap();
|
||||
assert!(
|
||||
untrusted
|
||||
.route("alpha.test", "bob@beta.test", xml)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
// Owning a valid certificate for alpha.test never authorizes gamma.test.
|
||||
alice.add_local_domain("gamma.test".into()).unwrap();
|
||||
assert!(
|
||||
alice
|
||||
.route(
|
||||
"gamma.test",
|
||||
"bob@beta.test",
|
||||
"<message from='alice@gamma.test' to='bob@beta.test'><body>forged</body></message>"
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(
|
||||
alice
|
||||
.route(
|
||||
"alpha.test",
|
||||
"bob@beta.test",
|
||||
"<message from='admin@gamma.test' to='bob@beta.test'/>"
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
// Source-domain authentication cannot be reused to deliver to a third domain.
|
||||
assert!(
|
||||
alice
|
||||
.route(
|
||||
"alpha.test",
|
||||
"bob@beta.test",
|
||||
"<message from='alice@alpha.test' to='bob@gamma.test'/>"
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn reuses_connections_serializes_writes_and_retires_idle_or_closed_peers() {
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fixture(temp.path());
|
||||
let (c, k, r) = material(temp.path(), "alpha.test");
|
||||
let mut alice = Federation::new("alpha.test".into(), c, k, r)
|
||||
.unwrap()
|
||||
.with_outbound_idle_timeout(Duration::from_millis(300))
|
||||
.unwrap();
|
||||
let (c, k, r) = material(temp.path(), "beta.test");
|
||||
let bob = Federation::new("beta.test".into(), c, k, r).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
alice
|
||||
.set_target(
|
||||
"beta.test".into(),
|
||||
"127.0.0.1".into(),
|
||||
listener.local_addr().unwrap().port(),
|
||||
)
|
||||
.unwrap();
|
||||
let accepted = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
let peers = Arc::new(Mutex::new(Vec::<tokio::task::AbortHandle>::new()));
|
||||
let (tx, mut rx) = mpsc::channel(64);
|
||||
let server = {
|
||||
let accepted = accepted.clone();
|
||||
let completed = completed.clone();
|
||||
let peers = peers.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
accepted.fetch_add(1, Ordering::SeqCst);
|
||||
let bob = bob.clone();
|
||||
let tx = tx.clone();
|
||||
let completed = completed.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = bob.accept(tcp, tx).await;
|
||||
completed.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
peers.lock().unwrap().push(handle.abort_handle());
|
||||
}
|
||||
})
|
||||
};
|
||||
let stanza = |id: usize| {
|
||||
format!(
|
||||
"<message xmlns='jabber:client' from='alice@alpha.test/r' to='bob@beta.test' id='message-{id}'><body>payload-{id}</body></message>"
|
||||
)
|
||||
};
|
||||
for id in 0..3 {
|
||||
alice
|
||||
.route("alpha.test", "bob@beta.test", &stanza(id))
|
||||
.await
|
||||
.unwrap();
|
||||
let received = tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(received.xml.contains(&format!("message-{id}")));
|
||||
}
|
||||
assert_eq!(
|
||||
accepted.load(Ordering::SeqCst),
|
||||
1,
|
||||
"sequential sends should reuse one authenticated TLS connection"
|
||||
);
|
||||
assert!(
|
||||
alice
|
||||
.route(
|
||||
"alpha.test",
|
||||
"bob@beta.test",
|
||||
"<message from='forged@gamma.test' to='bob@beta.test'/>"
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.load(Ordering::SeqCst),
|
||||
1,
|
||||
"source checks apply before reuse"
|
||||
);
|
||||
let mut jobs = vec![];
|
||||
for id in 3..23 {
|
||||
let alice = alice.clone();
|
||||
let xml = stanza(id);
|
||||
jobs.push(tokio::spawn(async move {
|
||||
alice
|
||||
.route("alpha.test", "bob@beta.test", &xml)
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
for job in jobs {
|
||||
job.await.unwrap();
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for _ in 3..23 {
|
||||
let received = tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let d = roxmltree::Document::parse(&received.xml).unwrap();
|
||||
ids.insert(d.root_element().attribute("id").unwrap().to_owned());
|
||||
}
|
||||
assert_eq!(ids.len(), 20);
|
||||
assert_eq!(
|
||||
accepted.load(Ordering::SeqCst),
|
||||
1,
|
||||
"concurrent clones share a per-pair connection"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert!(
|
||||
completed.load(Ordering::SeqCst) >= 1,
|
||||
"idle connection must close without another route call"
|
||||
);
|
||||
alice
|
||||
.route("alpha.test", "bob@beta.test", &stanza(23))
|
||||
.await
|
||||
.unwrap();
|
||||
let received = tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(received.xml.contains("message-23"));
|
||||
assert_eq!(accepted.load(Ordering::SeqCst), 2);
|
||||
peers.lock().unwrap().last().unwrap().abort();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
alice
|
||||
.route("alpha.test", "bob@beta.test", &stanza(24))
|
||||
.await
|
||||
.unwrap();
|
||||
let received = tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(received.xml.contains("message-24"));
|
||||
assert_eq!(accepted.load(Ordering::SeqCst), 3);
|
||||
assert!(rx.try_recv().is_err(), "no automatic duplicate deliveries");
|
||||
drop(alice);
|
||||
server.abort();
|
||||
for peer in peers.lock().unwrap().iter() {
|
||||
peer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Native interop test, deliberately opt-in: needs a built Prosody source tree,
|
||||
/// Lua 5.4, LuaSocket, LuaSec, LuaExpat and LuaFileSystem.
|
||||
///
|
||||
/// PROSODY_SOURCE=/path/to/prosody-cafd74b9c5ea PROSODY_LUA=/path/to/lua5.4 \
|
||||
/// cargo test --test federation_tls native_prosody_tls_external_bidirectional -- --ignored --nocapture
|
||||
///
|
||||
/// Lua's LUA_PATH/LUA_CPATH and the platform library path are inherited. The
|
||||
/// probe plugin overrides TCP discovery only; both peers still authenticate the
|
||||
/// original alpha.test/beta.test identities and trust only the generated CA.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires native Prosody cafd74b9c5ea and Lua TLS dependencies"]
|
||||
async fn native_prosody_tls_external_bidirectional() {
|
||||
use std::process::{Child, Stdio};
|
||||
use std::time::Duration;
|
||||
struct Native(Child);
|
||||
impl Drop for Native {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
fn lua_path(path: &Path) -> String {
|
||||
format!(
|
||||
"'{}'",
|
||||
path.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('\'', "\\'")
|
||||
)
|
||||
}
|
||||
let source = std::env::var_os("PROSODY_SOURCE")
|
||||
.map(std::path::PathBuf::from)
|
||||
.expect("set PROSODY_SOURCE to the built Prosody cafd74b9c5ea source tree");
|
||||
let source = source.canonicalize().unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(source.join("prosody.version"))
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"cafd74b9c5ea"
|
||||
);
|
||||
let lua = std::env::var_os("PROSODY_LUA").unwrap_or_else(|| "lua5.4".into());
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fixture(temp.path());
|
||||
// OpenSSL's TLS client validation requires clientAuth as well as serverAuth
|
||||
// for s2s certificates, unlike rustls's server-identity verifier.
|
||||
let ext = temp.path().join("alpha.test.ext");
|
||||
let extensions = fs::read_to_string(&ext).unwrap().replace(
|
||||
"extendedKeyUsage=serverAuth",
|
||||
"extendedKeyUsage=serverAuth,clientAuth",
|
||||
);
|
||||
fs::write(ext, extensions).unwrap();
|
||||
openssl(
|
||||
temp.path(),
|
||||
&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"alpha.test.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"alpha.test.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"alpha.test.ext",
|
||||
],
|
||||
);
|
||||
let (certs, key, roots) = material(temp.path(), "alpha.test");
|
||||
let mut rust = Federation::new("alpha.test".into(), certs, key, roots).unwrap();
|
||||
let rust_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let rust_port = rust_listener.local_addr().unwrap().port();
|
||||
let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let prosody_port = reservation.local_addr().unwrap().port();
|
||||
drop(reservation);
|
||||
rust.set_target("beta.test".into(), "127.0.0.1".into(), prosody_port)
|
||||
.unwrap();
|
||||
let plugins = temp.path().join("plugins");
|
||||
fs::create_dir(&plugins).unwrap();
|
||||
fs::create_dir(temp.path().join("data")).unwrap();
|
||||
let received_marker = temp.path().join("received");
|
||||
fs::write(plugins.join("mod_native_probe.lua"), format!(r#"
|
||||
local st = require "prosody.util.stanza";
|
||||
local basic = require "prosody.net.resolvers.basic";
|
||||
module:hook("s2sout-pre-connect", function(event)
|
||||
assert(event.session.to_host == "alpha.test");
|
||||
event.resolver = basic.new("127.0.0.1", {rust_port}, "tcp");
|
||||
end);
|
||||
module:hook("message/bare", function(event)
|
||||
local stanza = event.stanza;
|
||||
if stanza.attr.id ~= "rust-to-prosody" then return; end
|
||||
assert(event.origin.secure and event.origin.type == "s2sin");
|
||||
assert(event.origin.external_auth == "succeeded");
|
||||
assert(event.origin.cert_chain_status == "valid");
|
||||
assert(event.origin.cert_identity_status == "valid");
|
||||
local marker = assert(io.open({}, "w"));
|
||||
marker:write(stanza:get_child_text("body")); marker:close();
|
||||
module:send(st.message({{ from="bob@beta.test"; to="alice@alpha.test"; id="prosody-to-rust"; type="chat" }}):body("native-prosody-reply"));
|
||||
return true;
|
||||
end, 1000);
|
||||
"#, lua_path(&received_marker))).unwrap();
|
||||
let config = temp.path().join("prosody.cfg.lua");
|
||||
fs::write(
|
||||
&config,
|
||||
format!(
|
||||
r#"
|
||||
daemonize = false;
|
||||
run_as_root = true;
|
||||
interfaces = {{ "127.0.0.1" }};
|
||||
c2s_ports = {{}};
|
||||
s2s_ports = {{ {prosody_port} }};
|
||||
http_ports = {{}};
|
||||
https_ports = {{}};
|
||||
plugin_paths = {{ {} }};
|
||||
data_path = {};
|
||||
modules_enabled = {{ "tls"; "saslauth"; "native_probe" }};
|
||||
modules_disabled = {{ "dialback"; "s2s_bidi"; "offline"; "c2s" }};
|
||||
s2s_secure_auth = true;
|
||||
s2s_require_encryption = true;
|
||||
log = "*console";
|
||||
ssl = {{ key = {}; certificate = {}; cafile = {}; capath = false; }};
|
||||
VirtualHost "beta.test"
|
||||
authentication = "internal_hashed";
|
||||
"#,
|
||||
lua_path(&plugins),
|
||||
lua_path(&temp.path().join("data")),
|
||||
lua_path(&temp.path().join("beta.test.key")),
|
||||
lua_path(&temp.path().join("beta.test.pem")),
|
||||
lua_path(&temp.path().join("ca.pem"))
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let log_path = temp.path().join("prosody.log");
|
||||
let log = fs::File::create(&log_path).unwrap();
|
||||
let mut native = Native(
|
||||
Command::new(lua)
|
||||
.current_dir(&source)
|
||||
.env("PROSODY_SRCDIR", &source)
|
||||
.env("PROSODY_PLUGINDIR", source.join("plugins"))
|
||||
.arg(source.join("prosody"))
|
||||
.args(["-F", "--config"])
|
||||
.arg(&config)
|
||||
.stdout(Stdio::from(log.try_clone().unwrap()))
|
||||
.stderr(Stdio::from(log))
|
||||
.spawn()
|
||||
.expect("start native Prosody"),
|
||||
);
|
||||
let log_text = || fs::read_to_string(&log_path).unwrap_or_default();
|
||||
let startup = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
assert!(
|
||||
native.0.try_wait().unwrap().is_none(),
|
||||
"Prosody exited:\n{}",
|
||||
log_text()
|
||||
);
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", prosody_port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(startup.is_ok(), "Prosody did not listen:\n{}", log_text());
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
let receiving = {
|
||||
let rust = rust.clone();
|
||||
tokio::spawn(async move {
|
||||
let (tcp, _) = rust_listener.accept().await.unwrap();
|
||||
rust.accept(tcp, tx).await
|
||||
})
|
||||
};
|
||||
let outcome = rust.route("alpha.test", "bob@beta.test", "<message from='alice@alpha.test' to='bob@beta.test' id='rust-to-prosody' type='chat'><body>native-rust-probe</body></message>").await;
|
||||
assert!(
|
||||
outcome.is_ok(),
|
||||
"Rust -> Prosody: {outcome:?}\n{}",
|
||||
log_text()
|
||||
);
|
||||
let reply = tokio::time::timeout(Duration::from_secs(10), rx.recv()).await;
|
||||
assert!(
|
||||
matches!(&reply, Ok(Some(_))),
|
||||
"Prosody -> Rust: {reply:?}\n{}",
|
||||
log_text()
|
||||
);
|
||||
let reply = reply.unwrap().unwrap();
|
||||
assert_eq!(reply.from_domain, "beta.test");
|
||||
assert!(reply.xml.contains("prosody-to-rust"));
|
||||
assert!(reply.xml.contains("native-prosody-reply"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(received_marker).unwrap(),
|
||||
"native-rust-probe"
|
||||
);
|
||||
let logs = log_text();
|
||||
assert!(
|
||||
logs.contains("Accepting SASL EXTERNAL identity from alpha.test"),
|
||||
"{logs}"
|
||||
);
|
||||
assert!(
|
||||
logs.contains("SASL EXTERNAL with alpha.test succeeded"),
|
||||
"{logs}"
|
||||
);
|
||||
receiving.abort();
|
||||
}
|
||||
Vendored
+252
@@ -0,0 +1,252 @@
|
||||
-- ============================================================================
|
||||
-- БАЗОВАЯ КОНФИГУРАЦИЯ PROSODY для wthome.ru
|
||||
-- ============================================================================
|
||||
|
||||
-- Данные сервера
|
||||
data_path = "/var/lib/prosody"
|
||||
|
||||
-- Администраторы
|
||||
admins = { "wt@wthome.ru" }
|
||||
|
||||
external_addresses = { "88.204.18.107" }
|
||||
|
||||
-- Плагины (модули)
|
||||
modules_enabled = {
|
||||
-- Обязательные модули
|
||||
"roster"; -- Список контактов
|
||||
"saslauth"; -- Аутентификация
|
||||
"tls"; -- Шифрование TLS
|
||||
"disco"; -- Service Discovery (нужен для OMEMO)
|
||||
--"posix"; -- POSIX функции
|
||||
|
||||
-- OMEMO и современные клиенты
|
||||
"pep_simple"; -- Personal Eventing Protocol (ОБЯЗАТЕЛЬНО для OMEMO!)
|
||||
"private"; -- Приватное хранилище
|
||||
-- "vcard"; -- Визитные карточки
|
||||
"pep";
|
||||
"vcard_legacy";
|
||||
--"pep_vcard_avatar";
|
||||
--"pubsub"; -- Publish-Subscribe
|
||||
|
||||
-- Синхронизация и история
|
||||
"carbons"; -- Message Carbons (синхронизация между устройствами)
|
||||
"mam"; -- Message Archive Management (история сообщений)
|
||||
"smacks"; -- Stream Management (для мобильных клиентов)
|
||||
"csi_simple"; -- Client State Indication (оптимизация для мобильных)
|
||||
|
||||
-- Звонки (Jingle)
|
||||
"turn_external"; -- Внешний TURN сервер для звонков
|
||||
|
||||
-- Дополнительные возможности
|
||||
"register"; -- Регистрация аккаунтов
|
||||
"blocklist"; -- Блокировка контактов
|
||||
"bookmarks"; -- Закладки для конференций
|
||||
"ping"; -- Keep-alive
|
||||
"time"; -- Время сервера
|
||||
"version"; -- Версия сервера
|
||||
"uptime"; -- Время работы сервера
|
||||
|
||||
-- Web-клиенты
|
||||
"websocket"; -- WebSocket для веб-клиентов
|
||||
"bosh"; -- BOSH (устаревший, но для совместимости)
|
||||
|
||||
-- Файлы и медиа
|
||||
--"http_file_share"; -- HTTP File Sharing (XEP-0363)
|
||||
--"http_upload"; -- HTTP Upload (альтернатива)
|
||||
"admin_shell";
|
||||
"cloud_notify";
|
||||
-- "s2s_auth_dane";
|
||||
"s2s";
|
||||
"s2s_auth_certs";
|
||||
"seclabels";
|
||||
"sasl_ssdp";
|
||||
"sasl2";
|
||||
-- "admin_web";
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- ЛОГИРОВАНИЕ
|
||||
-- ============================================================================
|
||||
|
||||
log = {
|
||||
info = "*console";
|
||||
error = "*console";
|
||||
-- "*syslog"; -- Раскомментировать для syslog
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ БЕЗОПАСНОСТИ
|
||||
-- ============================================================================
|
||||
|
||||
-- Rate limiting
|
||||
limits = {
|
||||
c2s = {
|
||||
rate = "100kb/s";
|
||||
};
|
||||
s2sin = {
|
||||
rate = "300kb/s";
|
||||
};
|
||||
}
|
||||
|
||||
security_labels = {
|
||||
default_policy = "default";
|
||||
policies = {
|
||||
["default"] = {
|
||||
classifications = {
|
||||
{ name = "Unclassified", default = true },
|
||||
{ name = "Confidential" },
|
||||
{ name = "Secret" },
|
||||
{ name = "Top Secret" }
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
vcard_avatar = {
|
||||
sync_to_vcard = true;
|
||||
sync_to_pep = true;
|
||||
}
|
||||
|
||||
-- Отключаем ненужное
|
||||
--modules_disabled = {
|
||||
-- "s2s"; -- Отключить федерацию (убрать если нужна связь с другими серверами)
|
||||
--}
|
||||
|
||||
smacks_max_unacked = 100
|
||||
smacks_hibernation_time = 300
|
||||
allow_unencrypted_plain_auth = false
|
||||
|
||||
push_notification_with_body = true
|
||||
push_notification_with_sender = true
|
||||
push_notification_important_body = "New message"
|
||||
push_max_devices = 50
|
||||
push_max_errors = 32
|
||||
push_max_hibernation_timeout = 259200
|
||||
|
||||
-- Аутентификация
|
||||
allow_registration = true -- Отключить публичную регистрацию (безопасность)
|
||||
|
||||
-- TLS настройки
|
||||
c2s_require_encryption = true
|
||||
s2s_require_encryption = true
|
||||
s2s_secure_auth = true
|
||||
|
||||
-- Порты
|
||||
interfaces = { "0.0.0.0", "::" }
|
||||
c2s_ports = { 5222 }
|
||||
s2s_ports = { 5269 }
|
||||
http_ports = { 5280 }
|
||||
https_ports = { 5281 }
|
||||
|
||||
-- HTTP интерфейсы для файлов и BOSH
|
||||
http_interfaces = { "*" }
|
||||
https_interfaces = { "*" }
|
||||
|
||||
-- BOSH/WebSocket настройки
|
||||
consider_bosh_secure = true
|
||||
consider_websocket_secure = true
|
||||
--cross_domain_bosh = true
|
||||
--cross_domain_websocket = true
|
||||
|
||||
http_cors_override = {
|
||||
bosh = {
|
||||
enabled = true;
|
||||
origins = {
|
||||
"https://wthome.ru";
|
||||
};
|
||||
};
|
||||
websocket = {
|
||||
enabled = true;
|
||||
origins = {
|
||||
"https://wthome.ru";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- НАСТРОЙКА ЗВОНКОВ (TURN/STUN)
|
||||
-- ============================================================================
|
||||
|
||||
-- Настройка внешнего TURN сервера для Jingle звонков
|
||||
turn_external_host = "turn.wthome.ru" -- или IP сервера
|
||||
turn_external_port = 3478
|
||||
turn_external_secret = "TEST-ONLY-NOT-A-REAL-SECRET"
|
||||
turn_external_ttl = 86400
|
||||
|
||||
-- Альтернативно: если coturn на том же сервере
|
||||
-- turn_external_host = "wthome.ru"
|
||||
|
||||
-- ============================================================================
|
||||
-- НАСТРОЙКА OMEMO (через PEP)
|
||||
-- ============================================================================
|
||||
|
||||
-- PEP настройки для OMEMO
|
||||
pep_max_items = 10000
|
||||
|
||||
-- OMEMO работает через PEP автоматически при включенном mod_pep
|
||||
-- Никаких дополнительных настроек не требуется!
|
||||
|
||||
-- ============================================================================
|
||||
-- MAM (История сообщений)
|
||||
-- ============================================================================
|
||||
|
||||
archive_expires_after = "never" -- never delete history archive
|
||||
default_archive_policy = true -- Включить архивирование по умолчанию
|
||||
|
||||
-- ============================================================================
|
||||
-- HTTP File Share (отправка файлов)
|
||||
-- ============================================================================
|
||||
|
||||
-- Настройка компонента для файлов (см. ниже в VirtualHost)
|
||||
--http_file_share_expires_after = 7 * 24 * 60 * 60 -- 7 дней
|
||||
--http_file_share_global_quota = 1024 * 1024 * 1024 * 10 -- 10 GB
|
||||
|
||||
-- ============================================================================
|
||||
-- ВИРТУАЛЬНЫЙ ХОСТ
|
||||
-- ============================================================================
|
||||
|
||||
VirtualHost "wthome.ru"
|
||||
authentication = "internal_hashed"
|
||||
-- SSL сертификаты (Let's Encrypt или самоподписанные)
|
||||
ssl = {
|
||||
key = "/etc/prosody/certs/privkey.pem";
|
||||
certificate = "/etc/prosody/certs/fullchain.pem";
|
||||
}
|
||||
|
||||
-- Дополнительные модули для этого домена
|
||||
modules_enabled = {
|
||||
"mam"; -- Убедимся что MAM включен
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- КОМПОНЕНТЫ
|
||||
-- ============================================================================
|
||||
|
||||
-- Конференции (MUC)
|
||||
Component "conference.wthome.ru" "muc"
|
||||
name = "Conference Rooms"
|
||||
restrict_room_creation = "local" -- Только локальные пользователи создают комнаты
|
||||
max_history_messages = 100
|
||||
muc_tombstones = true
|
||||
|
||||
-- MAM для конференций (групповой чат)
|
||||
modules_enabled = {
|
||||
-- "mam";
|
||||
"muc_mam";
|
||||
}
|
||||
mam_muc_archive_expires_after = "1w"
|
||||
|
||||
-- Обмен файлами
|
||||
Component "upload.wthome.ru" "http_file_share"
|
||||
name = "File Sharing"
|
||||
http_file_share_expires_after = "never" -- 7 дней
|
||||
http_file_share_global_quota = 1024 * 1024 * 1024 * 100 -- 100 GB
|
||||
http_file_share_size_limit = 1024 * 1024 * 1024 * 10 -- 10 GB
|
||||
|
||||
-- PubSub (для OMEMO и других фич)
|
||||
Component "pubsub.wthome.ru" "pubsub"
|
||||
name = "PubSub Service"
|
||||
restrict_node_creation = false
|
||||
|
||||
-- Отключаем устаревшие методы аутентификации
|
||||
-- disable_sasl_mechanisms = { "DIGEST-MD5", "PLAIN" } -- , "SCRAM-SHA-1" }
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated TLS regression for Gajim's XEP-0258/XEP-0077 predicates and operations.
|
||||
|
||||
Usage: python3 tests/gajim_discovery_network.py [target/release/rust-xmpp]
|
||||
Only temporary accounts, certificates, and server data are used.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from _wire_client import BIN, Client, OPEN
|
||||
|
||||
DISCO = 'http://jabber.org/protocol/disco#info'
|
||||
REGISTER = 'jabber:iq:register'
|
||||
LABEL = 'urn:xmpp:sec-label:0'
|
||||
CATALOGS = ('urn:xmpp:sec-label:catalog:2', 'urn:xmpp:sec-label:catalog:0')
|
||||
STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas'
|
||||
|
||||
|
||||
class Unauthenticated(Client):
|
||||
def __init__(self, port, cert):
|
||||
self.sock = socket.create_connection(('127.0.0.1', port), timeout=5)
|
||||
self.buf = b''
|
||||
self.queue = []
|
||||
self.send(OPEN)
|
||||
self.until(b'</stream:features>')
|
||||
self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
||||
assert b'proceed' in self.until(b'/>')
|
||||
self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket(
|
||||
self.sock, server_hostname='localhost')
|
||||
self.send(OPEN)
|
||||
self.features = self.until(b'</stream:features>')
|
||||
|
||||
|
||||
def iq(client, identifier, payload, target='localhost', typ='get', error=False):
|
||||
client.send(f"<iq type='{typ}' id='{identifier}' to='{target}'>{payload}</iq>")
|
||||
return client.iq(identifier, error=error)
|
||||
|
||||
|
||||
def error_condition(response, expected):
|
||||
assert response.find(f'{{jabber:client}}error/{{{STANZAS}}}{expected}') is not None, ET.tostring(response)
|
||||
|
||||
|
||||
def check_authenticated(client, enabled):
|
||||
for target in ('localhost', 'alice@localhost'):
|
||||
response = iq(client, 'disco-' + target, f"<query xmlns='{DISCO}'/>", target)
|
||||
features = {node.get('var') for node in response.iter(f'{{{DISCO}}}feature')}
|
||||
for namespace in (LABEL, REGISTER, CATALOGS[0]):
|
||||
assert (namespace in features) == enabled, (target, enabled, namespace, features)
|
||||
for index, namespace in enumerate(CATALOGS):
|
||||
for target in ('localhost', 'alice@localhost'):
|
||||
response = iq(client, f'catalog-{index}-{target}',
|
||||
f"<catalog xmlns='{namespace}' to='bob@localhost'/>", target,
|
||||
error=not enabled)
|
||||
if not enabled:
|
||||
error_condition(response, 'service-unavailable')
|
||||
continue
|
||||
catalog = response.find(f'{{{namespace}}}catalog')
|
||||
assert catalog is not None, ET.tostring(response)
|
||||
assert catalog.get('to') == 'bob@localhost', ET.tostring(response)
|
||||
entry = catalog.find(f'{{{namespace}}}item') if index == 0 else catalog.find(f'{{{LABEL}}}securitylabel')
|
||||
assert entry is not None, ET.tostring(response)
|
||||
assert entry.get('selector') == 'Public' and entry.get('default') == 'true', ET.tostring(response)
|
||||
label = entry.find(f'{{{LABEL}}}securitylabel') if index == 0 else entry
|
||||
assert label.find(f'{{{LABEL}}}label').text == 'PUBLIC', ET.tostring(response)
|
||||
response = iq(client, 'account-registration', f"<query xmlns='{REGISTER}'/>", error=not enabled)
|
||||
if enabled:
|
||||
query = response.find(f'{{{REGISTER}}}query')
|
||||
assert query is not None and query.find(f'{{{REGISTER}}}registered') is not None, ET.tostring(response)
|
||||
assert query.find(f'{{{REGISTER}}}username').text == 'alice', ET.tostring(response)
|
||||
iq(client, 'change-password',
|
||||
f"<query xmlns='{REGISTER}'><username>alice</username><password>changed-secret</password></query>", typ='set')
|
||||
else:
|
||||
error_condition(response, 'service-unavailable')
|
||||
response = iq(client, 'change-password-disabled',
|
||||
f"<query xmlns='{REGISTER}'><password>changed-secret</password></query>",
|
||||
typ='set', error=True)
|
||||
error_condition(response, 'service-unavailable')
|
||||
|
||||
|
||||
def scenario(binary, root, enabled, allow_registration):
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
name = f'enabled-{enabled}-registration-{allow_registration}'
|
||||
modules = ['tls', 'saslauth', 'pep', 'disco', 'ping']
|
||||
if enabled:
|
||||
modules += ['seclabels', 'register']
|
||||
cfg = {'global': {
|
||||
'data_path': str(root / name), 'interfaces': ['127.0.0.1'],
|
||||
'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [],
|
||||
'modules_enabled': modules, 'allow_registration': allow_registration,
|
||||
'security_labels': [{'name': 'Public', 'label': 'PUBLIC', 'default': True}],
|
||||
}, 'hosts': {'localhost': {'options': {
|
||||
'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
||||
'ssl': {'key': str(root / 'key.pem'), 'certificate': str(root / 'cert.pem')},
|
||||
}}}}
|
||||
config = root / f'{name}.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
|
||||
input='original-secret\n', text=True, check=True, capture_output=True)
|
||||
with (root / f'{name}.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
clients = []
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
client = Client(port, root / 'cert.pem', 'alice', 'original-secret')
|
||||
clients.append(client)
|
||||
check_authenticated(client, enabled)
|
||||
# An actual fresh SASL login proves the password update was persisted.
|
||||
logged_in = Client(port, root / 'cert.pem', 'alice',
|
||||
'changed-secret' if enabled else 'original-secret', 'new-login')
|
||||
clients.append(logged_in)
|
||||
logged_in.sync()
|
||||
unauth = Unauthenticated(port, root / 'cert.pem')
|
||||
clients.append(unauth)
|
||||
registration_enabled = enabled and allow_registration
|
||||
assert (b'http://jabber.org/features/iq-register' in unauth.features) == registration_enabled, unauth.features
|
||||
response = iq(unauth, 'ibr-form', f"<query xmlns='{REGISTER}'/>", error=not registration_enabled)
|
||||
if registration_enabled:
|
||||
query = response.find(f'{{{REGISTER}}}query')
|
||||
assert query is not None and query.find(f'{{{REGISTER}}}username') is not None, ET.tostring(response)
|
||||
else:
|
||||
error_condition(response, 'forbidden')
|
||||
response = iq(unauth, 'ibr-create',
|
||||
f"<query xmlns='{REGISTER}'><username>newuser</username><password>ibr-secret</password></query>",
|
||||
typ='set', error=not registration_enabled)
|
||||
if registration_enabled:
|
||||
registered = Client(port, root / 'cert.pem', 'newuser', 'ibr-secret', 'ibr-login')
|
||||
clients.append(registered)
|
||||
registered.sync()
|
||||
# XEP-0077 removal must acknowledge the request and revoke login.
|
||||
iq(registered, 'ibr-remove', f"<query xmlns='{REGISTER}'><remove/></query>", typ='set')
|
||||
deleted_login = Unauthenticated(port, root / 'cert.pem')
|
||||
clients.append(deleted_login)
|
||||
credentials = base64.b64encode(b'\0newuser\0ibr-secret').decode()
|
||||
deleted_login.send(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{credentials}</auth>")
|
||||
assert b'failure' in deleted_login.until(b'>')
|
||||
assert not list((root / name).glob('*/accounts/newuser.dat'))
|
||||
else:
|
||||
error_condition(response, 'forbidden')
|
||||
# Rejection must not create an account on disk.
|
||||
assert not list((root / name).glob('*/accounts/newuser.dat'))
|
||||
print('PASS:', name)
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-gajim-discovery-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||||
'-keyout', str(root / 'key.pem'), '-out', str(root / 'cert.pem'),
|
||||
'-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for enabled in (False, True):
|
||||
for allow_registration in (False, True):
|
||||
scenario(binary, root, enabled, allow_registration)
|
||||
print('PASS: Gajim domain/account discovery, both label catalogs, authenticated password changes, IBR registration gates and fresh logins')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else str(BIN))
|
||||
@@ -0,0 +1,234 @@
|
||||
use rust_xmpp::{
|
||||
config::{Config, Host},
|
||||
extras,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::Instant;
|
||||
|
||||
fn config() -> Config {
|
||||
let mut config = Config::default();
|
||||
config.hosts.insert("example.test".into(), Host::default());
|
||||
config
|
||||
.global
|
||||
.insert("modules_enabled".into(), json!(["seclabels"]));
|
||||
config
|
||||
}
|
||||
|
||||
fn request(config: &Config, namespace: &str, extra: &str) -> String {
|
||||
request_at(config, "example.test", namespace, extra)
|
||||
}
|
||||
|
||||
fn request_at(config: &Config, domain: &str, namespace: &str, extra: &str) -> String {
|
||||
let xml = format!(
|
||||
"<iq type='get' id='catalog-1' to='{domain}'><catalog xmlns='{namespace}'{extra}/></iq>"
|
||||
);
|
||||
let document = roxmltree::Document::parse(&xml).unwrap();
|
||||
extras::handle(config, domain, document.root_element(), Instant::now())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies
|
||||
.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_catalogs_serve_both_versions_and_preserve_requested_target() {
|
||||
let mut config = config();
|
||||
config
|
||||
.global
|
||||
.insert("security_catalog_name".into(), json!("Team & partners"));
|
||||
config.global.insert("security_labels".into(), json!({
|
||||
"Classified": {"SECRET": {
|
||||
"label": "<esssecuritylabel xmlns='urn:example:policy'><classification>SECRET</classification></esssecuritylabel>",
|
||||
"display": "Secret & restricted", "color": "black", "bgcolor": "aqua", "default": true
|
||||
}}
|
||||
}));
|
||||
for namespace in [
|
||||
"urn:xmpp:sec-label:catalog:2",
|
||||
"urn:xmpp:sec-label:catalog:0",
|
||||
] {
|
||||
let response = request(&config, namespace, " to='friend@example.test' ");
|
||||
let document = roxmltree::Document::parse(&response).unwrap();
|
||||
let iq = document.root_element();
|
||||
assert_eq!(iq.attribute("type"), Some("result"));
|
||||
assert_eq!(iq.attribute("id"), Some("catalog-1"));
|
||||
let catalog = iq.first_element_child().unwrap();
|
||||
assert_eq!(catalog.tag_name().namespace(), Some(namespace));
|
||||
assert_eq!(catalog.attribute("to"), Some("friend@example.test"));
|
||||
assert_eq!(catalog.attribute("name"), Some("Team & partners"));
|
||||
let item = catalog.first_element_child().unwrap();
|
||||
assert_eq!(item.attribute("selector"), Some("Classified|SECRET"));
|
||||
assert_eq!(item.attribute("default"), Some("true"));
|
||||
let label = if namespace.ends_with(":2") {
|
||||
assert_eq!(item.tag_name().name(), "item");
|
||||
item.first_element_child().unwrap()
|
||||
} else {
|
||||
item
|
||||
};
|
||||
assert_eq!(label.tag_name().name(), "securitylabel");
|
||||
assert_eq!(label.tag_name().namespace(), Some("urn:xmpp:sec-label:0"));
|
||||
let display = label.first_element_child().unwrap();
|
||||
assert_eq!(display.text(), Some("Secret & restricted"));
|
||||
assert_eq!(display.attribute("fgcolor"), Some("black"));
|
||||
assert_eq!(display.attribute("bgcolor"), Some("aqua"));
|
||||
assert!(
|
||||
label
|
||||
.descendants()
|
||||
.any(|node| node.has_tag_name(("urn:example:policy", "esssecuritylabel")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_catalog_default_is_a_real_label_and_module_can_be_disabled() {
|
||||
let mut config = config();
|
||||
let response = request(&config, "urn:xmpp:sec-label:catalog:2", "");
|
||||
let document = roxmltree::Document::parse(&response).unwrap();
|
||||
let item = document
|
||||
.descendants()
|
||||
.find(|n| n.has_tag_name(("urn:xmpp:sec-label:catalog:2", "item")))
|
||||
.unwrap();
|
||||
assert_eq!(item.attribute("selector"), Some("Unclassified"));
|
||||
assert_eq!(item.attribute("default"), Some("true"));
|
||||
assert!(
|
||||
item.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:xmpp:sec-label:0", "label"))
|
||||
&& n.text() == Some("Unclassified"))
|
||||
);
|
||||
config.global.insert("modules_enabled".into(), json!([]));
|
||||
let document = roxmltree::Document::parse(
|
||||
"<iq type='get'><catalog xmlns='urn:xmpp:sec-label:catalog:2'/></iq>",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
extras::handle(
|
||||
&config,
|
||||
"example.test",
|
||||
document.root_element(),
|
||||
Instant::now()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_security_policy_configuration_returns_explicit_error() {
|
||||
let mut config = config();
|
||||
config
|
||||
.global
|
||||
.insert("security_labels".into(), json!({"policies": ["SECRET"]}));
|
||||
let response = request(&config, "urn:xmpp:sec-label:catalog:2", "");
|
||||
let document = roxmltree::Document::parse(&response).unwrap();
|
||||
assert_eq!(document.root_element().attribute("type"), Some("error"));
|
||||
assert!(
|
||||
document
|
||||
.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "not-acceptable")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_example_policy_configuration_exposes_classifications_as_catalog_labels() {
|
||||
let config: Config = serde_json::from_str(include_str!("../examples/config.json")).unwrap();
|
||||
for namespace in [
|
||||
"urn:xmpp:sec-label:catalog:2",
|
||||
"urn:xmpp:sec-label:catalog:0",
|
||||
] {
|
||||
let response = request_at(&config, "wthome.ru", namespace, " to='wt@wthome.ru'");
|
||||
let document = roxmltree::Document::parse(&response).unwrap();
|
||||
assert_eq!(document.root_element().attribute("type"), Some("result"));
|
||||
let catalog = document.root_element().first_element_child().unwrap();
|
||||
assert_eq!(catalog.attribute("to"), Some("wt@wthome.ru"));
|
||||
let entries: Vec<_> = catalog
|
||||
.children()
|
||||
.filter(|node| node.is_element())
|
||||
.collect();
|
||||
let names = ["Unclassified", "Confidential", "Secret", "Top Secret"];
|
||||
assert_eq!(entries.len(), names.len());
|
||||
for (index, (entry, name)) in entries.iter().zip(names).enumerate() {
|
||||
assert_eq!(entry.attribute("selector"), Some(name));
|
||||
assert_eq!(entry.attribute("default"), (index == 0).then_some("true"));
|
||||
let label = entry
|
||||
.descendants()
|
||||
.find(|node| node.has_tag_name(("urn:xmpp:sec-label:0", "label")))
|
||||
.unwrap();
|
||||
assert_eq!(label.text(), Some(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_catalog_selects_default_policy_and_maps_display_and_explicit_label() {
|
||||
let mut config = config();
|
||||
config.global.insert("security_labels".into(), json!({
|
||||
"default_policy": "selected", "policies": {
|
||||
"other": {"classifications": [{"name": "Other"}]},
|
||||
"selected": {"classifications": [{"name": "Secret", "label": "CLASSIFIED", "display": "Restricted", "color": "black", "bgcolor": "red", "default": true}]}
|
||||
}
|
||||
}));
|
||||
let response = request(&config, "urn:xmpp:sec-label:catalog:2", "");
|
||||
let document = roxmltree::Document::parse(&response).unwrap();
|
||||
let item = document
|
||||
.descendants()
|
||||
.find(|node| node.has_tag_name(("urn:xmpp:sec-label:catalog:2", "item")))
|
||||
.unwrap();
|
||||
assert_eq!(item.attribute("selector"), Some("Secret"));
|
||||
assert_eq!(item.attribute("default"), Some("true"));
|
||||
let display = item
|
||||
.descendants()
|
||||
.find(|node| node.has_tag_name(("urn:xmpp:sec-label:0", "displaymarking")))
|
||||
.unwrap();
|
||||
assert_eq!(display.text(), Some("Restricted"));
|
||||
assert_eq!(display.attribute("fgcolor"), Some("black"));
|
||||
assert_eq!(display.attribute("bgcolor"), Some("red"));
|
||||
assert!(
|
||||
item.descendants()
|
||||
.any(|node| node.has_tag_name(("urn:xmpp:sec-label:0", "label"))
|
||||
&& node.text() == Some("CLASSIFIED"))
|
||||
);
|
||||
assert!(!response.contains("Other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_unknown_policy_catalog_schemas_are_rejected() {
|
||||
let cases = [
|
||||
json!({"policies": {}}),
|
||||
json!({"default_policy": "default", "policies": {}}),
|
||||
json!({"default_policy": "default", "policies": []}),
|
||||
json!({"default_policy": "default", "policies": {"default": {}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": {}}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{}]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": " "}]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": 1}]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "default": "yes"}]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "color": 1}]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "label": false}]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Public", "label": "<invalid"}]}}}),
|
||||
json!({"default_policy": "default", "enforce": true, "policies": {"default": {"classifications": []}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [], "members": ["alice"]}}}),
|
||||
json!({"default_policy": "default", "policies": {"default": {"classifications": [{"name": "Secret", "acl": "admins"}]}}}),
|
||||
];
|
||||
for value in cases {
|
||||
let mut config = config();
|
||||
config
|
||||
.global
|
||||
.insert("security_labels".into(), value.clone());
|
||||
for namespace in [
|
||||
"urn:xmpp:sec-label:catalog:2",
|
||||
"urn:xmpp:sec-label:catalog:0",
|
||||
] {
|
||||
let response = request(&config, namespace, "");
|
||||
let document = roxmltree::Document::parse(&response).unwrap();
|
||||
assert_eq!(
|
||||
document.root_element().attribute("type"),
|
||||
Some("error"),
|
||||
"{value}"
|
||||
);
|
||||
assert!(
|
||||
document.descendants().any(|node| node
|
||||
.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "not-acceptable"))),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use rust_xmpp::{config::Config, http_altconnect::links, http_config::HttpConfig};
|
||||
use serde_json::json;
|
||||
fn config(global: serde_json::Value, local: serde_json::Value) -> Config {
|
||||
serde_json::from_value(json!({"global":global,"hosts":{"xmpp.test":{"options":local}}}))
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn native_link_order_schemes_and_loaded_module_gates() {
|
||||
let mut c = config(
|
||||
json!({"modules_enabled":["http_altconnect","bosh","websocket"]}),
|
||||
json!({}),
|
||||
);
|
||||
let all = links(&c, "xmpp.test", true, 5281).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].rel, "urn:xmpp:alt-connections:xbosh");
|
||||
assert_eq!(all[0].href, "https://xmpp.test:5281/http-bind");
|
||||
assert_eq!(all[1].rel, "urn:xmpp:alt-connections:websocket");
|
||||
assert_eq!(all[1].href, "wss://xmpp.test:5281/xmpp-websocket");
|
||||
c.hosts
|
||||
.get_mut("xmpp.test")
|
||||
.unwrap()
|
||||
.options
|
||||
.insert("advertise_bosh".into(), json!(false));
|
||||
assert_eq!(
|
||||
links(&c, "xmpp.test", false, 80).unwrap()[0].href,
|
||||
"ws://xmpp.test/xmpp-websocket"
|
||||
);
|
||||
c.global
|
||||
.insert("modules_disabled".into(), json!(["websocket"]));
|
||||
assert!(links(&c, "xmpp.test", true, 443).unwrap().is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn public_prefix_and_host_paths_inherit_independently() {
|
||||
let c = config(
|
||||
json!({"modules_enabled":["http_altconnect","bosh","websocket"],"http_host":"web.test","http_external_url":"https://global.test/base","http_paths":{"bosh":"/bind-$host","altconnect":"/discover-$host"}}),
|
||||
json!({"http_external_url":"https://proxy.test:443/public/","http_paths":{"websocket":"/ws"}}),
|
||||
);
|
||||
let all = links(&c, "xmpp.test", false, 5280).unwrap();
|
||||
assert_eq!(all[0].href, "https://proxy.test/public/bind-xmpp.test");
|
||||
assert_eq!(all[1].href, "wss://proxy.test/public/ws");
|
||||
assert_eq!(
|
||||
HttpConfig::for_host(&c, "xmpp.test")
|
||||
.unwrap()
|
||||
.altconnect_path,
|
||||
"/discover-xmpp.test"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn bad_advertisement_types_and_route_collisions_are_rejected() {
|
||||
let c = config(json!({"advertise_websocket":"yes"}), json!({}));
|
||||
assert!(c.validate().is_err());
|
||||
assert!(links(&c, "xmpp.test", false, 5280).is_err());
|
||||
for module in ["bosh", "websocket"] {
|
||||
for path in ["/.well-known/host-meta", "/.well-known/host-meta.json/"] {
|
||||
let c = config(
|
||||
json!({"modules_enabled":["http_altconnect",module],"http_paths":{module:path}}),
|
||||
json!({}),
|
||||
);
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real-binary XEP-0156 host-meta regression using isolated loopback listeners.
|
||||
|
||||
Requires Python 3.11+, openssl and websockets. No external services or trust
|
||||
changes: the temporary certificate is trusted only by this test's TLS client.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
import copy
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import websockets
|
||||
|
||||
XRD = 'http://docs.oasis-open.org/ns/xri/xrd-1.0'
|
||||
BOSH_REL = 'urn:xmpp:alt-connections:xbosh'
|
||||
WS_REL = 'urn:xmpp:alt-connections:websocket'
|
||||
FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing'
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def request(port, path, method='GET', context=None, host=None):
|
||||
connection = (http.client.HTTPSConnection('localhost', port, context=context, timeout=5)
|
||||
if context else http.client.HTTPConnection('127.0.0.1', port, timeout=5))
|
||||
try:
|
||||
connection.request(method, path, headers={
|
||||
'Host': host or f'localhost:{port}', 'Origin': 'https://unrelated.example',
|
||||
'Access-Control-Request-Method': 'GET', 'Connection': 'close'})
|
||||
response = connection.getresponse()
|
||||
return response.status, {k.lower(): v for k, v in response.getheaders()}, response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server(binary, config, root, label):
|
||||
directory = root / label
|
||||
directory.mkdir()
|
||||
config = copy.deepcopy(config)
|
||||
config['global']['data_path'] = str(directory / 'data')
|
||||
path = directory / 'config.json'
|
||||
path.write_text(json.dumps(config))
|
||||
ports = config['global']['http_ports'] + config['global']['https_ports']
|
||||
with (directory / 'server.log').open('w+') as log:
|
||||
process = subprocess.Popen([binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for port in ports:
|
||||
for _ in range(100):
|
||||
assert process.poll() is None, f'{label}: server exited during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), .1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError(f'{label}: listener {port} startup timed out')
|
||||
yield
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(f'{label} SERVER LOG:\n' + log.read())
|
||||
raise
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def documents(port, expected, context=None, base='/.well-known', host=None):
|
||||
"""Both serializations describe exactly the same ordered transport links."""
|
||||
for suffix in ('', '?callback=PRIVATE_CALLBACK&cache=1'):
|
||||
for extension in ('', '.json'):
|
||||
path = base + '/host-meta' + extension + suffix
|
||||
status, headers, body = request(port, path, context=context, host=host)
|
||||
assert status == 200, (path, status, body)
|
||||
assert headers.get('access-control-allow-origin') == '*', headers
|
||||
content_type = headers.get('content-type', '').split(';')[0]
|
||||
if extension:
|
||||
assert content_type == 'application/json', headers
|
||||
parsed = json.loads(body)
|
||||
assert isinstance(parsed.get('links'), list), parsed
|
||||
links = [(link['rel'], link['href']) for link in parsed['links']]
|
||||
else:
|
||||
assert content_type == 'application/xrd+xml', headers
|
||||
parsed = ET.fromstring(body)
|
||||
assert parsed.tag == f'{{{XRD}}}XRD', body
|
||||
links = [(link.get('rel'), link.get('href')) for link in parsed.findall(f'{{{XRD}}}Link')]
|
||||
assert links == expected, (path, expected, links)
|
||||
assert b'PRIVATE_CALLBACK' not in body, body
|
||||
status, head_headers, body = request(port, path, 'HEAD', context, host)
|
||||
assert status == 200 and body == b'', (path, status, body)
|
||||
assert head_headers.get('access-control-allow-origin') == '*', head_headers
|
||||
status, options_headers, body = request(port, path, 'OPTIONS', context, host)
|
||||
assert 200 <= status < 300 and body == b'', (path, status, body)
|
||||
assert options_headers.get('access-control-allow-origin') == '*', options_headers
|
||||
allowed = {s.strip() for s in options_headers.get('access-control-allow-methods', '').split(',')}
|
||||
assert {'GET', 'HEAD', 'OPTIONS'} <= allowed, options_headers
|
||||
for extension in ('', '.json'):
|
||||
status, _, body = request(port, base + '/host-meta' + extension, 'POST', context, host)
|
||||
assert status == 405, (status, body)
|
||||
|
||||
|
||||
async def websocket_exchange(port, context, path='/xmpp-websocket'):
|
||||
async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context,
|
||||
origin='https://browser.example', subprotocols=['xmpp'], open_timeout=5) as ws:
|
||||
assert ws.subprotocol == 'xmpp'
|
||||
await ws.send(f'<open xmlns="{FRAMING}" to="localhost" version="1.0"/>')
|
||||
assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag == f'{{{FRAMING}}}open'
|
||||
assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag.endswith('}features')
|
||||
await ws.send(f'<close xmlns="{FRAMING}"/>')
|
||||
assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag == f'{{{FRAMING}}}close'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp'))
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-http-altconnect-') as temporary:
|
||||
root = Path(temporary)
|
||||
cert, key = root / 'cert.pem', root / 'key.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256',
|
||||
'-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost',
|
||||
'-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(key), '-out', str(cert)],
|
||||
check=True, capture_output=True)
|
||||
context = ssl.create_default_context(cafile=str(cert))
|
||||
http, https = free_port(), free_port()
|
||||
while https == http:
|
||||
https = free_port()
|
||||
config = {'global': {'interfaces': ['127.0.0.1'], 'http_interfaces': ['127.0.0.1'],
|
||||
'https_interfaces': ['127.0.0.1'], 'c2s_ports': [], 's2s_ports': [],
|
||||
'http_ports': [http], 'https_ports': [https],
|
||||
'modules_enabled': ['tls', 'bosh', 'websocket', 'http_altconnect', 'saslauth'],
|
||||
'http_cors_override': {'websocket': {'origins': ['https://browser.example']}},
|
||||
'ssl': {'certificate': str(cert), 'key': str(key)}},
|
||||
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}}
|
||||
secure = [(BOSH_REL, f'https://localhost:{https}/http-bind'),
|
||||
(WS_REL, f'wss://localhost:{https}/xmpp-websocket')]
|
||||
with server(args.binary, config, root, 'defaults'):
|
||||
documents(http, secure)
|
||||
documents(https, secure, context)
|
||||
for port, tls in ((http, None), (https, context)):
|
||||
for host in ('unknown.example', 'localhost:999999', 'bad host'):
|
||||
status, _, body = request(port, '/.well-known/host-meta.json', context=tls, host=host)
|
||||
assert status in (400, 404), (host, status, body)
|
||||
asyncio.run(websocket_exchange(https, context))
|
||||
|
||||
custom = copy.deepcopy(config)
|
||||
custom['global'].update({'http_paths': {'altconnect': 'discovery/$host/',
|
||||
'bosh': '/custom/bind', 'websocket': '/custom/socket'},
|
||||
'http_external_url': 'https://global.example/ignored'})
|
||||
custom['hosts']['localhost']['options'].update({
|
||||
'http_external_url': 'https://proxy.example:443/public/prefix/', 'http_host': 'chat.example'})
|
||||
public = [(BOSH_REL, 'https://proxy.example/public/prefix/custom/bind'),
|
||||
(WS_REL, 'wss://proxy.example/public/prefix/custom/socket')]
|
||||
with server(args.binary, custom, root, 'custom'):
|
||||
for port, tls in ((http, None), (https, context)):
|
||||
documents(port, public, tls, '/discovery/localhost', 'chat.example')
|
||||
for path in ('/.well-known/host-meta', '/.well-known/host-meta.json',
|
||||
'/public/prefix/discovery/localhost/host-meta'):
|
||||
status, _, body = request(port, path, context=tls, host='chat.example')
|
||||
assert status == 404, (path, status, body)
|
||||
status, _, body = request(port, '/discovery/localhost/host-meta.json', context=tls)
|
||||
assert status == 404, (status, body)
|
||||
|
||||
disabled = copy.deepcopy(config)
|
||||
disabled['hosts']['localhost']['options']['modules_disabled'] = ['http_altconnect']
|
||||
with server(args.binary, disabled, root, 'disabled'):
|
||||
for port, tls in ((http, None), (https, context)):
|
||||
for extension in ('', '.json'):
|
||||
status, _, body = request(port, '/.well-known/host-meta' + extension, context=tls)
|
||||
assert status == 404, (status, body)
|
||||
asyncio.run(websocket_exchange(https, context))
|
||||
|
||||
for label, disabled_modules, flags, expected in (
|
||||
('bosh-disabled', ['bosh'], {}, [secure[1]]),
|
||||
('websocket-disabled', ['websocket'], {}, [secure[0]]),
|
||||
('no-transports', ['bosh', 'websocket'], {}, []),
|
||||
('bosh-hidden', [], {'advertise_bosh': False}, [secure[1]]),
|
||||
('websocket-hidden', [], {'advertise_websocket': False}, [secure[0]]),
|
||||
('all-hidden', [], {'advertise_bosh': False, 'advertise_websocket': False}, [])):
|
||||
variant = copy.deepcopy(config)
|
||||
variant['hosts']['localhost']['options'].update(flags, modules_disabled=disabled_modules)
|
||||
with server(args.binary, variant, root, label):
|
||||
documents(http, expected)
|
||||
|
||||
plain = copy.deepcopy(config)
|
||||
plain['global']['https_ports'] = []
|
||||
plain_links = [(BOSH_REL, f'http://localhost:{http}/http-bind'),
|
||||
(WS_REL, f'ws://localhost:{http}/xmpp-websocket')]
|
||||
with server(args.binary, plain, root, 'http-only'):
|
||||
documents(http, plain_links)
|
||||
print('PASS: XEP-0156 XRD/JRD parity, CORS, safe methods, ignored JSONP, host checks, secure URL preference, custom mounts/public prefixes, module and advertisement gates, HTTP fallback and verified WSS framing')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback network regression for custom routes and TLS-terminating proxies.
|
||||
|
||||
Requires websockets and openssl. Uses an actual verified HTTPS proxy, HTTP
|
||||
upstream, and isolated server data; never contacts an external service.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
from contextlib import contextmanager
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import select
|
||||
import socket
|
||||
import socketserver
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import websockets
|
||||
|
||||
ORIGIN = 'https://browser.example'
|
||||
WS_PATH = '/private/xmpp/socket'
|
||||
BOSH_PATH = '/private/xmpp/bind'
|
||||
PUBLIC_WS = '/public/chat/socket'
|
||||
PUBLIC_BOSH = '/public/chat/bind'
|
||||
BOSH = 'http://jabber.org/protocol/httpbind'
|
||||
FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing'
|
||||
SASL = 'urn:ietf:params:xml:ns:xmpp-sasl'
|
||||
CLIENT = 'jabber:client'
|
||||
BIND = 'urn:ietf:params:xml:ns:xmpp-bind'
|
||||
PASSWORD = 'proxy-integration-password'
|
||||
PLAIN = base64.b64encode(f'\0alice\0{PASSWORD}'.encode()).decode()
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def request(port, path, method='GET', body=b'', origin=ORIGIN,
|
||||
forwarded=None, context=None, address='127.0.0.1', upgrade=False, rfc=False):
|
||||
headers = {'Host': f'localhost:{port}', 'Connection': 'close'}
|
||||
if origin is not None:
|
||||
headers['Origin'] = origin
|
||||
if forwarded is not None:
|
||||
headers['Forwarded' if rfc else 'X-Forwarded-Proto'] = (
|
||||
f'for=198.51.100.77;proto={forwarded}' if rfc else forwarded)
|
||||
if method == 'POST':
|
||||
headers['Content-Type'] = 'text/xml'
|
||||
if upgrade:
|
||||
headers.update({'Connection': 'keep-alive, Upgrade', 'Upgrade': 'websocket',
|
||||
'Sec-WebSocket-Version': '13',
|
||||
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
||||
'Sec-WebSocket-Protocol': 'xmpp'})
|
||||
connection = (http.client.HTTPSConnection(address, port, context=context, timeout=8)
|
||||
if context else http.client.HTTPConnection(address, port, timeout=8))
|
||||
try:
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server(binary, config, root):
|
||||
path = root / 'config.json'
|
||||
path.write_text(json.dumps(config))
|
||||
with (root / 'server.log').open('w+') as log:
|
||||
process = subprocess.Popen([binary, 'serve', str(path), '--experimental'],
|
||||
stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert process.poll() is None, 'server exited during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', config['global']['http_ports'][0]), .1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('HTTP listener startup timed out')
|
||||
yield
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print('SERVER LOG:\n' + log.read())
|
||||
raise
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
class Proxy(socketserver.ThreadingTCPServer):
|
||||
"""Small TLS-terminating, path-rewriting TCP proxy supporting WS upgrades."""
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, backend, tls, source='127.0.0.2', proto='https', rfc=False):
|
||||
self.backend, self.tls, self.source, self.proto = backend, tls, source, proto
|
||||
self.rfc = rfc
|
||||
self.errors = []
|
||||
super().__init__(('127.0.0.1', 0), ProxyHandler)
|
||||
|
||||
def get_request(self):
|
||||
stream, peer = super().get_request()
|
||||
try:
|
||||
return self.tls.wrap_socket(stream, server_side=True), peer
|
||||
except BaseException:
|
||||
stream.close()
|
||||
raise
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
import traceback
|
||||
self.errors.append(traceback.format_exc())
|
||||
|
||||
|
||||
class ProxyHandler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
client = self.request
|
||||
client.settimeout(8)
|
||||
incoming = b''
|
||||
while b'\r\n\r\n' not in incoming:
|
||||
chunk = client.recv(4096)
|
||||
if not chunk:
|
||||
return
|
||||
incoming += chunk
|
||||
assert len(incoming) < 65536, 'oversized proxy request headers'
|
||||
head, payload = incoming.split(b'\r\n\r\n', 1)
|
||||
lines = head.decode('latin1').split('\r\n')
|
||||
method, path, version = lines[0].split(' ', 2)
|
||||
for public, internal in ((PUBLIC_WS, WS_PATH), (PUBLIC_BOSH, BOSH_PATH)):
|
||||
if path == public or path.startswith(public + '/') or path.startswith(public + '?'):
|
||||
path = internal + path[len(public):]
|
||||
break
|
||||
# The fixture models a proxy which replaces client-supplied metadata.
|
||||
headers = [line for line in lines[1:] if line.split(':', 1)[0].lower()
|
||||
not in ('forwarded', 'x-forwarded-for', 'x-forwarded-proto')]
|
||||
if self.server.rfc:
|
||||
headers += [f'Forwarded: for=198.51.100.77;proto={self.server.proto}']
|
||||
else:
|
||||
headers += ['X-Forwarded-For: 198.51.100.7', f'X-Forwarded-Proto: {self.server.proto}']
|
||||
outgoing = ('\r\n'.join([f'{method} {path} {version}', *headers]) + '\r\n\r\n').encode('latin1')
|
||||
with socket.create_connection(('127.0.0.1', self.server.backend), timeout=8,
|
||||
source_address=(self.server.source, 0)) as upstream:
|
||||
upstream.sendall(outgoing + payload)
|
||||
while True:
|
||||
# SSLSocket may already have decrypted bytes buffered internally.
|
||||
readable = [client] if client.pending() else select.select([client, upstream], [], [], 8)[0]
|
||||
if not readable:
|
||||
return
|
||||
for source in readable:
|
||||
try:
|
||||
data = source.recv(65536)
|
||||
if not data:
|
||||
return
|
||||
(upstream if source is client else client).sendall(data)
|
||||
except (ConnectionResetError, BrokenPipeError, ssl.SSLEOFError):
|
||||
# Raw handshake probes intentionally close after HTTP101.
|
||||
return
|
||||
|
||||
|
||||
@contextmanager
|
||||
def proxy(backend, tls, **kwargs):
|
||||
with Proxy(backend, tls, **kwargs) as fixture:
|
||||
worker = threading.Thread(target=fixture.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
try:
|
||||
yield fixture.server_address[1]
|
||||
assert not fixture.errors, '\n'.join(fixture.errors)
|
||||
finally:
|
||||
fixture.shutdown()
|
||||
worker.join(timeout=5)
|
||||
|
||||
|
||||
async def websocket_exchange(port, context, path, authenticate=False):
|
||||
async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context,
|
||||
origin=ORIGIN, subprotocols=['xmpp'], open_timeout=8) as ws:
|
||||
assert ws.subprotocol == 'xmpp'
|
||||
await ws.send(f'<open xmlns="{FRAMING}" to="localhost" version="1.0"/>')
|
||||
opened = ET.fromstring(await asyncio.wait_for(ws.recv(), 5))
|
||||
assert opened.tag == f'{{{FRAMING}}}open', ET.tostring(opened)
|
||||
features = ET.fromstring(await asyncio.wait_for(ws.recv(), 5))
|
||||
assert features.find(f'.//{{{SASL}}}mechanism') is not None, ET.tostring(features)
|
||||
if authenticate:
|
||||
await ws.send(f'<auth xmlns="{SASL}" mechanism="PLAIN">{PLAIN}</auth>')
|
||||
success = ET.fromstring(await asyncio.wait_for(ws.recv(), 5))
|
||||
assert success.tag == f'{{{SASL}}}success', ET.tostring(success)
|
||||
await ws.send(f'<open xmlns="{FRAMING}" to="localhost" version="1.0"/>')
|
||||
assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag == f'{{{FRAMING}}}open'
|
||||
assert ET.fromstring(await asyncio.wait_for(ws.recv(), 5)).tag.endswith('}features')
|
||||
await ws.send(f'<iq xmlns="{CLIENT}" type="set" id="bind"><bind xmlns="{BIND}"><resource>proxy-ws</resource></bind></iq>')
|
||||
bound = ET.fromstring(await asyncio.wait_for(ws.recv(), 5))
|
||||
assert bound.get('type') == 'result' and bound.get('id') == 'bind', ET.tostring(bound)
|
||||
assert bound.find(f'.//{{{BIND}}}jid').text == 'alice@localhost/proxy-ws', ET.tostring(bound)
|
||||
await ws.send(f'<iq xmlns="{CLIENT}" type="get" to="localhost" id="proxy-ping"><ping xmlns="urn:xmpp:ping"/></iq>')
|
||||
pong = ET.fromstring(await asyncio.wait_for(ws.recv(), 5))
|
||||
assert pong.get('type') == 'result' and pong.get('id') == 'proxy-ping', ET.tostring(pong)
|
||||
await ws.send(f'<close xmlns="{FRAMING}"/>')
|
||||
closed = ET.fromstring(await asyncio.wait_for(ws.recv(), 5))
|
||||
assert closed.tag == f'{{{FRAMING}}}close', ET.tostring(closed)
|
||||
|
||||
|
||||
def bosh_exchange(port, context, path, authenticate=False):
|
||||
first = f'<body xmlns="{BOSH}" rid="100" to="localhost" wait="1" hold="1" ver="1.11"/>'.encode()
|
||||
status, _, payload = request(port, path, 'POST', first, context=context)
|
||||
assert status == 200, (status, payload)
|
||||
body = ET.fromstring(payload)
|
||||
sid = body.get('sid')
|
||||
assert sid and body.find(f'.//{{{SASL}}}mechanism') is not None, payload
|
||||
rid = 101
|
||||
|
||||
def send(stanza='', extra=''):
|
||||
nonlocal rid
|
||||
document = f'<body xmlns="{BOSH}" sid="{sid}" rid="{rid}" {extra}>{stanza}</body>'.encode()
|
||||
rid += 1
|
||||
status, _, response = request(port, path, 'POST', document, context=context)
|
||||
assert status == 200, (status, response)
|
||||
return document, response, ET.fromstring(response)
|
||||
|
||||
if authenticate:
|
||||
_, response, body = send(f'<auth xmlns="{SASL}" mechanism="PLAIN">{PLAIN}</auth>')
|
||||
assert body.find(f'{{{SASL}}}success') is not None, response
|
||||
_, response, body = send(extra='xmlns:xmpp="urn:xmpp:xbosh" xmpp:restart="true" to="localhost"')
|
||||
assert any(n.tag.endswith('}features') for n in body), response
|
||||
_, response, body = send(f'<iq xmlns="{CLIENT}" type="set" id="bind"><bind xmlns="{BIND}"><resource>proxy-bosh</resource></bind></iq>')
|
||||
assert body.find(f'.//{{{BIND}}}jid').text == 'alice@localhost/proxy-bosh', response
|
||||
poll, response, body = send(f'<iq xmlns="{CLIENT}" type="get" to="localhost" id="proxy-ping"><ping xmlns="urn:xmpp:ping"/></iq>')
|
||||
assert any(n.get('id') == 'proxy-ping' and n.get('type') == 'result' for n in body), response
|
||||
else:
|
||||
poll, response, body = send()
|
||||
assert body.get('type') != 'terminate', response
|
||||
# A retried RID after URI rewriting must return its exact cached response.
|
||||
retried_status, _, retried = request(port, path, 'POST', poll, context=context)
|
||||
assert retried_status == 200 and retried == response, (response, retried)
|
||||
_, response, body = send(extra='type="terminate"')
|
||||
assert body.get('type') == 'terminate', response
|
||||
|
||||
|
||||
def policy_checks(port, context, ws_path, bosh_path):
|
||||
for path, method, upgrade in ((ws_path, 'GET', True), (bosh_path, 'OPTIONS', False)):
|
||||
status, headers, body = request(port, path, method, context=context, upgrade=upgrade)
|
||||
assert status == (101 if upgrade else 200), (path, status, body)
|
||||
assert headers.get('access-control-allow-origin') == ORIGIN, headers
|
||||
for origin in ('https://wrong.example', ORIGIN + ':8443', 'null'):
|
||||
status, _, body = request(port, path, method, context=context,
|
||||
upgrade=upgrade, origin=origin)
|
||||
assert status == 403, (path, origin, status, body)
|
||||
status, headers, _ = request(port, path, method, context=context,
|
||||
upgrade=upgrade, origin=None)
|
||||
assert status == (101 if upgrade else 200), (path, status)
|
||||
assert 'access-control-allow-origin' not in headers, headers
|
||||
status, _, body = request(port, bosh_path, 'POST', context=context,
|
||||
origin='https://wrong.example')
|
||||
assert status == 403, (bosh_path, status, body)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp'))
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-http-proxy-paths-') as temporary:
|
||||
root = Path(temporary)
|
||||
cert, key = root / 'cert.pem', root / 'key.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256',
|
||||
'-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1',
|
||||
'-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(key), '-out', str(cert)],
|
||||
check=True, capture_output=True)
|
||||
client_tls = ssl.create_default_context(cafile=str(cert))
|
||||
proxy_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
proxy_tls.load_cert_chain(str(cert), str(key))
|
||||
backend, https = free_port(), free_port()
|
||||
interfaces = ['127.0.0.1']
|
||||
try:
|
||||
with socket.socket(socket.AF_INET6) as probe:
|
||||
probe.bind(('::1', 0))
|
||||
interfaces.append('::1')
|
||||
except OSError:
|
||||
pass
|
||||
config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
|
||||
'http_interfaces': interfaces, 'https_interfaces': interfaces,
|
||||
'c2s_ports': [], 's2s_ports': [], 'http_ports': [backend], 'https_ports': [https],
|
||||
'http_host': 'localhost', 'http_default_host': 'localhost', 'trusted_proxies': ['127.0.0.2/32'],
|
||||
'http_legacy_x_forwarded': True,
|
||||
'consider_websocket_secure': False, 'consider_bosh_secure': False,
|
||||
'http_paths': {'websocket': WS_PATH, 'bosh': BOSH_PATH},
|
||||
'http_cors_override': {name: {'origins': [ORIGIN]} for name in ('websocket', 'bosh')},
|
||||
'modules_enabled': ['tls', 'websocket', 'bosh', 'saslauth', 'ping'],
|
||||
'ssl': {'certificate': str(cert), 'key': str(key)}},
|
||||
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}}
|
||||
config_path = root / 'config.json'
|
||||
config_path.write_text(json.dumps(config))
|
||||
subprocess.run([args.binary, 'create-user', str(config_path), 'alice', '--password-stdin'],
|
||||
input=PASSWORD + '\n', text=True, check=True, capture_output=True)
|
||||
with server(args.binary, config, root):
|
||||
policy_checks(https, client_tls, WS_PATH, BOSH_PATH)
|
||||
for suffix in ('', '/', '?cache=1'):
|
||||
asyncio.run(websocket_exchange(https, client_tls, WS_PATH + suffix))
|
||||
bosh_exchange(https, client_tls, BOSH_PATH + suffix)
|
||||
for path in ('/xmpp-websocket', '/xmpp-websocket/', '/http-bind', '/http-bind/'):
|
||||
status, _, body = request(https, path, context=client_tls, upgrade='websocket' in path)
|
||||
assert status == 404, (path, status, body)
|
||||
for address in interfaces:
|
||||
for path, method in ((WS_PATH, 'GET'), (BOSH_PATH, 'POST')):
|
||||
for spoof in (None, 'https'):
|
||||
status, _, body = request(backend, path, method, forwarded=spoof,
|
||||
address=address, upgrade=method == 'GET')
|
||||
assert status == 403, (address, path, spoof, status, body)
|
||||
if address == '::1':
|
||||
status, _, body = request(https, WS_PATH, context=client_tls,
|
||||
address=address, upgrade=True)
|
||||
assert status == 101, (address, status, body)
|
||||
with proxy(backend, proxy_tls) as public:
|
||||
policy_checks(public, client_tls, PUBLIC_WS, PUBLIC_BOSH)
|
||||
asyncio.run(websocket_exchange(public, client_tls, PUBLIC_WS, authenticate=True))
|
||||
bosh_exchange(public, client_tls, PUBLIC_BOSH, authenticate=True)
|
||||
# Both TLS on an untrusted peer and a trusted peer declaring plain
|
||||
# HTTP must fail the same gate, before any XMPP session is created.
|
||||
for options in ({'source': '127.0.0.1'}, {'proto': 'http'}):
|
||||
with proxy(backend, proxy_tls, **options) as public:
|
||||
for path, method in ((PUBLIC_WS, 'GET'), (PUBLIC_BOSH, 'POST')):
|
||||
status, _, body = request(public, path, method, context=client_tls,
|
||||
upgrade=method == 'GET')
|
||||
assert status == 403, (options, path, status, body)
|
||||
config['global']['http_legacy_x_forwarded'] = False
|
||||
with server(args.binary, config, root):
|
||||
with proxy(backend, proxy_tls, rfc=True) as public:
|
||||
asyncio.run(websocket_exchange(public, client_tls, PUBLIC_WS, authenticate=True))
|
||||
bosh_exchange(public, client_tls, PUBLIC_BOSH, authenticate=True)
|
||||
for path, method in ((WS_PATH, 'GET'), (BOSH_PATH, 'POST')):
|
||||
status, _, body = request(backend, path, method, forwarded='https',
|
||||
rfc=True, upgrade=method == 'GET')
|
||||
assert status == 403, (path, status, body)
|
||||
for options in ({'source': '127.0.0.1'}, {'proto': 'http'}):
|
||||
with proxy(backend, proxy_tls, rfc=True, **options) as public:
|
||||
for path, method in ((PUBLIC_WS, 'GET'), (PUBLIC_BOSH, 'POST')):
|
||||
status, _, body = request(public, path, method, context=client_tls,
|
||||
upgrade=method == 'GET')
|
||||
assert status == 403, (options, path, status, body)
|
||||
print('PASS: custom WS/BOSH routes, default-route 404, exact Origin policy, verified TLS proxy rewrite, trusted HTTPS metadata, untrusted/spoofed metadata denial, IPv6 when available, authenticated WS/BOSH bind/ping in legacy and RFC7239 modes and cached BOSH RID retry')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,482 @@
|
||||
use rust_xmpp::{
|
||||
http_service::serve_router,
|
||||
http_transport::{ConnectionHandler, bosh_router, websocket_router},
|
||||
wire::Framer,
|
||||
};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
};
|
||||
fn handler(count: Arc<AtomicUsize>) -> ConnectionHandler {
|
||||
Arc::new(move |mut stream| {
|
||||
let count = count.clone();
|
||||
Box::pin(async move {
|
||||
let mut f = Framer::default();
|
||||
while let Ok(xml) = f.next(&mut stream).await {
|
||||
if xml.starts_with("<stream:stream") {
|
||||
stream.write_all(b"<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' from='example.test' id='123' version='1.0'><stream:features><mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'><mechanism>PLAIN</mechanism></mechanisms></stream:features>").await.unwrap();
|
||||
} else if xml == "</stream:stream>" {
|
||||
break;
|
||||
} else {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
stream.write_all(xml.as_bytes()).await.unwrap();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
async fn http(addr: std::net::SocketAddr, body: &str) -> String {
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
let req = format!(
|
||||
"POST /http-bind HTTP/1.1\r\nHost: example.test\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(req.as_bytes()).await.unwrap();
|
||||
let mut bytes = vec![];
|
||||
stream.read_to_end(&mut bytes).await.unwrap();
|
||||
String::from_utf8(bytes)
|
||||
.unwrap()
|
||||
.split_once("\r\n\r\n")
|
||||
.unwrap()
|
||||
.1
|
||||
.into()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn bosh_session_retries_restart_termination() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = bosh_router(handler(count.clone()));
|
||||
let task = tokio::spawn(serve_router(listener, None, app));
|
||||
let initial=http(addr,"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='100' wait='1' hold='1'/>").await;
|
||||
let doc = roxmltree::Document::parse(&initial).unwrap();
|
||||
let sid = doc.root_element().attribute("sid").unwrap().to_string();
|
||||
assert!(initial.contains("mechanisms"));
|
||||
let msg = format!(
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' sid='{sid}' rid='101'><message xmlns='jabber:client'><body>hello</body></message></body>"
|
||||
);
|
||||
let response = http(addr, &msg).await;
|
||||
assert!(response.contains("hello"));
|
||||
let replay = http(addr, &msg).await;
|
||||
assert_eq!(replay, response);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
let restart=http(addr,&format!("<body xmlns='http://jabber.org/protocol/httpbind' xmlns:xmpp='urn:xmpp:xbosh' sid='{sid}' rid='102' xmpp:restart='true'/>")).await;
|
||||
assert!(restart.contains("mechanisms"));
|
||||
let end=http(addr,&format!("<body xmlns='http://jabber.org/protocol/httpbind' sid='{sid}' rid='103' type='terminate'/>")).await;
|
||||
assert!(end.contains("type='terminate'"));
|
||||
task.abort();
|
||||
}
|
||||
async fn ws_send(s: &mut TcpStream, text: &str) {
|
||||
let bytes = text.as_bytes();
|
||||
let mut frame = vec![0x81];
|
||||
if bytes.len() < 126 {
|
||||
frame.push(0x80 | bytes.len() as u8)
|
||||
} else {
|
||||
frame.push(0x80 | 126);
|
||||
frame.extend_from_slice(&(bytes.len() as u16).to_be_bytes());
|
||||
}
|
||||
frame.extend_from_slice(&[1, 2, 3, 4]);
|
||||
frame.extend(
|
||||
bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| b ^ [1, 2, 3, 4][i % 4]),
|
||||
);
|
||||
s.write_all(&frame).await.unwrap();
|
||||
}
|
||||
async fn ws_read(s: &mut TcpStream) -> String {
|
||||
let mut h = [0; 2];
|
||||
s.read_exact(&mut h).await.unwrap();
|
||||
assert_eq!(h[0], 0x81);
|
||||
let n = if h[1] == 126 {
|
||||
s.read_u16().await.unwrap() as usize
|
||||
} else {
|
||||
h[1] as usize
|
||||
};
|
||||
let mut b = vec![0; n];
|
||||
s.read_exact(&mut b).await.unwrap();
|
||||
String::from_utf8(b).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn websocket_frames_are_independent_xml() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = websocket_router(handler(count.clone()));
|
||||
let task = tokio::spawn(serve_router(listener, None, app));
|
||||
let mut s = TcpStream::connect(addr).await.unwrap();
|
||||
s.write_all(b"GET /xmpp-websocket HTTP/1.1\r\nHost: example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: xmpp\r\n\r\n").await.unwrap();
|
||||
let mut header = vec![];
|
||||
loop {
|
||||
header.push(s.read_u8().await.unwrap());
|
||||
if header.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
String::from_utf8(header)
|
||||
.unwrap()
|
||||
.starts_with("HTTP/1.1 101")
|
||||
);
|
||||
ws_send(
|
||||
&mut s,
|
||||
"<open xmlns='urn:ietf:params:xml:ns:xmpp-framing' to='example.test' version='1.0'/>",
|
||||
)
|
||||
.await;
|
||||
let open = ws_read(&mut s).await;
|
||||
assert!(open.contains("<open"));
|
||||
let features = ws_read(&mut s).await;
|
||||
assert!(roxmltree::Document::parse(&features).is_ok());
|
||||
ws_send(
|
||||
&mut s,
|
||||
"<message xmlns='jabber:client'><body>hi</body></message>",
|
||||
)
|
||||
.await;
|
||||
let message = ws_read(&mut s).await;
|
||||
assert!(message.contains("hi"));
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
drop(s);
|
||||
task.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn configured_origin_is_enforced() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let router = rust_xmpp::http_transport::with_origins(
|
||||
bosh_router(handler(Arc::new(AtomicUsize::new(0)))),
|
||||
vec!["https://allowed.test".into()],
|
||||
);
|
||||
let task = tokio::spawn(serve_router(listener, None, router));
|
||||
for (origin, status) in [
|
||||
("https://evil.test", "403"),
|
||||
("https://allowed.test", "200"),
|
||||
] {
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
let request = format!(
|
||||
"OPTIONS /http-bind HTTP/1.1\r\nHost: example.test\r\nOrigin: {origin}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
let mut result = String::new();
|
||||
stream.read_to_string(&mut result).await.unwrap();
|
||||
assert!(
|
||||
result.starts_with(&format!("HTTP/1.1 {status}")),
|
||||
"{result}"
|
||||
);
|
||||
if status == "200" {
|
||||
assert!(result.contains("access-control-allow-origin: https://allowed.test"));
|
||||
}
|
||||
}
|
||||
task.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn bosh_reorders_two_requests_without_replaying_stanzas() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let task = tokio::spawn(serve_router(
|
||||
listener,
|
||||
None,
|
||||
bosh_router(handler(count.clone())),
|
||||
));
|
||||
let first = http(
|
||||
addr,
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='1' wait='1'/>",
|
||||
)
|
||||
.await;
|
||||
let doc = roxmltree::Document::parse(&first).unwrap();
|
||||
let sid = doc.root_element().attribute("sid").unwrap();
|
||||
let newer = format!(
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' sid='{sid}' rid='3'><message xmlns='jabber:client'><body>three</body></message></body>"
|
||||
);
|
||||
let older = format!(
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' sid='{sid}' rid='2'><message xmlns='jabber:client'><body>two</body></message></body>"
|
||||
);
|
||||
let a = tokio::spawn(async move { http(addr, &newer).await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
let b = http(addr, &older).await;
|
||||
let a = a.await.unwrap();
|
||||
assert!(
|
||||
!a.contains("terminate") && !b.contains("terminate"),
|
||||
"{a} {b}"
|
||||
);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 2);
|
||||
assert!(format!("{b}{a}").contains("three"));
|
||||
task.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn bosh_preserves_namespace_inherited_from_body() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let task = tokio::spawn(serve_router(
|
||||
listener,
|
||||
None,
|
||||
bosh_router(handler(count.clone())),
|
||||
));
|
||||
let first = http(
|
||||
addr,
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='1' wait='1'/>",
|
||||
)
|
||||
.await;
|
||||
let doc = roxmltree::Document::parse(&first).unwrap();
|
||||
let sid = doc.root_element().attribute("sid").unwrap();
|
||||
let reply=http(addr,&format!("<body xmlns='http://jabber.org/protocol/httpbind' xmlns:c='jabber:client' sid='{sid}' rid='2'><c:message><c:body>namespaced</c:body></c:message></body>")).await;
|
||||
let parsed = roxmltree::Document::parse(&reply).unwrap();
|
||||
assert!(
|
||||
parsed
|
||||
.descendants()
|
||||
.any(|n| n.tag_name().name() == "message"
|
||||
&& n.tag_name().namespace() == Some("jabber:client")),
|
||||
"{reply}"
|
||||
);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
async fn raw_http(addr: std::net::SocketAddr, request: &str) -> String {
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
let mut result = String::new();
|
||||
stream.read_to_string(&mut result).await.unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_paths_and_cors_preflight_match_prosody() {
|
||||
use rust_xmpp::http_transport::with_origins;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let h = handler(Arc::new(AtomicUsize::new(0)));
|
||||
let app = with_origins(
|
||||
bosh_router(h.clone()).merge(websocket_router(h)),
|
||||
vec!["https://wthome.ru".into()],
|
||||
);
|
||||
let task = tokio::spawn(serve_router(listener, None, app));
|
||||
for path in [
|
||||
"/http-bind",
|
||||
"/http-bind/",
|
||||
"/xmpp-websocket",
|
||||
"/xmpp-websocket/",
|
||||
] {
|
||||
let result = raw_http(addr, &format!(
|
||||
"OPTIONS {path} HTTP/1.1\r\nHost: example.test\r\nOrigin: https://wthome.ru\r\nAccess-Control-Request-Method: {}\r\nAccess-Control-Request-Headers: Content-Type\r\nConnection: close\r\n\r\n",
|
||||
if path.starts_with("/http-bind") { "POST" } else { "GET" }
|
||||
)).await;
|
||||
assert!(result.starts_with("HTTP/1.1 200"), "{path}: {result}");
|
||||
assert!(
|
||||
result.contains("access-control-allow-origin: https://wthome.ru"),
|
||||
"{result}"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.to_ascii_lowercase()
|
||||
.contains("access-control-allow-headers: content-type"),
|
||||
"{result}"
|
||||
);
|
||||
}
|
||||
let body =
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='100' wait='1'/>";
|
||||
let result = raw_http(addr, &format!(
|
||||
"POST /http-bind/ HTTP/1.1\r\nHost: example.test\r\nOrigin: https://wthome.ru\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()
|
||||
)).await;
|
||||
assert!(
|
||||
result.starts_with("HTTP/1.1 200") && result.contains("mechanisms"),
|
||||
"{result}"
|
||||
);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bosh_retry_survives_aborted_http_request_without_replaying_stanzas() {
|
||||
use tokio::sync::Notify;
|
||||
let seen = Arc::new(Notify::new());
|
||||
let release = Arc::new(Notify::new());
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let h: ConnectionHandler = {
|
||||
let seen = seen.clone();
|
||||
let release = release.clone();
|
||||
let count = count.clone();
|
||||
Arc::new(move |mut stream| {
|
||||
let seen = seen.clone();
|
||||
let release = release.clone();
|
||||
let count = count.clone();
|
||||
Box::pin(async move {
|
||||
let mut f = Framer::default();
|
||||
while let Ok(xml) = f.next(&mut stream).await {
|
||||
if xml.starts_with("<stream:stream") {
|
||||
stream.write_all(b"<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' from='example.test' version='1.0'><stream:features/>").await.unwrap();
|
||||
} else {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
seen.notify_one();
|
||||
release.notified().await;
|
||||
stream.write_all(xml.as_bytes()).await.unwrap();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let task = tokio::spawn(serve_router(listener, None, bosh_router(h)));
|
||||
let initial = http(
|
||||
addr,
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='1' wait='1'/>",
|
||||
)
|
||||
.await;
|
||||
let doc = roxmltree::Document::parse(&initial).unwrap();
|
||||
let sid = doc.root_element().attribute("sid").unwrap();
|
||||
let body = format!(
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' sid='{sid}' rid='2'><message xmlns='jabber:client'><body>once</body></message></body>"
|
||||
);
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
stream.write_all(format!("POST /http-bind HTTP/1.1\r\nHost: example.test\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), seen.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
socket2::SockRef::from(&stream)
|
||||
.set_linger(Some(std::time::Duration::ZERO))
|
||||
.unwrap();
|
||||
drop(stream);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let retry_body = body.clone();
|
||||
let retry = tokio::spawn(async move { http(addr, &retry_body).await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
release.notify_one();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(2), retry)
|
||||
.await
|
||||
.expect("retry must receive the original response even after HTTP disconnect")
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.contains("once") && !result.contains("terminate"),
|
||||
"{result}"
|
||||
);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(http(addr, &body).await, result);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bosh_termination_waits_for_graceful_cleanup_before_reconnect() {
|
||||
let cleaned = Arc::new(AtomicUsize::new(0));
|
||||
let h: ConnectionHandler = {
|
||||
let cleaned = cleaned.clone();
|
||||
Arc::new(move |mut stream| {
|
||||
let cleaned = cleaned.clone();
|
||||
Box::pin(async move {
|
||||
let mut framer = Framer::default();
|
||||
while let Ok(xml) = framer.next(&mut stream).await {
|
||||
if xml.starts_with("<stream:stream") {
|
||||
stream.write_all(b"<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' from='example.test' version='1.0'><stream:features/>").await.unwrap();
|
||||
} else if xml == "</stream:stream>" {
|
||||
stream.write_all(b"</stream:stream>").await.unwrap();
|
||||
// The stream response can arrive before backend cleanup ends.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
cleaned.fetch_add(1, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let task = tokio::spawn(serve_router(listener, None, bosh_router(h)));
|
||||
let initial = http(
|
||||
addr,
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='1' wait='1'/>",
|
||||
)
|
||||
.await;
|
||||
let doc = roxmltree::Document::parse(&initial).unwrap();
|
||||
let sid = doc.root_element().attribute("sid").unwrap();
|
||||
let end = http(addr, &format!("<body xmlns='http://jabber.org/protocol/httpbind' sid='{sid}' rid='2' type='terminate'/>")).await;
|
||||
assert!(end.contains("type='terminate'"), "{end}");
|
||||
assert_eq!(
|
||||
cleaned.load(Ordering::SeqCst),
|
||||
1,
|
||||
"termination must finish graceful backend cleanup before acknowledging the client"
|
||||
);
|
||||
let reconnected = http(
|
||||
addr,
|
||||
"<body xmlns='http://jabber.org/protocol/httpbind' to='example.test' rid='10' wait='1'/>",
|
||||
)
|
||||
.await;
|
||||
assert!(reconnected.contains("sid="), "{reconnected}");
|
||||
task.abort();
|
||||
}
|
||||
|
||||
async fn websocket_cleanup(explicit_close: bool) {
|
||||
let cleaned = Arc::new(tokio::sync::Notify::new());
|
||||
let outcome = Arc::new(AtomicUsize::new(0));
|
||||
let h: ConnectionHandler = {
|
||||
let cleaned = cleaned.clone();
|
||||
let outcome = outcome.clone();
|
||||
Arc::new(move |mut stream| {
|
||||
let cleaned = cleaned.clone();
|
||||
let outcome = outcome.clone();
|
||||
Box::pin(async move {
|
||||
let mut graceful = false;
|
||||
let mut f = Framer::default();
|
||||
while let Ok(xml) = f.next(&mut stream).await {
|
||||
if xml.starts_with("<stream:stream") {
|
||||
stream.write_all(b"<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' from='example.test' version='1.0'><stream:features/>").await.unwrap();
|
||||
} else if xml == "</stream:stream>" {
|
||||
graceful = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Simulate cleanup that yields after receiving close or EOF.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
outcome.store(if graceful { 1 } else { 2 }, Ordering::SeqCst);
|
||||
cleaned.notify_one();
|
||||
})
|
||||
})
|
||||
};
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let task = tokio::spawn(serve_router(listener, None, websocket_router(h)));
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
stream.write_all(b"GET /xmpp-websocket HTTP/1.1\r\nHost: example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: xmpp\r\n\r\n").await.unwrap();
|
||||
let mut header = Vec::new();
|
||||
while !header.ends_with(b"\r\n\r\n") {
|
||||
header.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
ws_send(
|
||||
&mut stream,
|
||||
"<open xmlns='urn:ietf:params:xml:ns:xmpp-framing' to='example.test' version='1.0'/>",
|
||||
)
|
||||
.await;
|
||||
ws_read(&mut stream).await;
|
||||
ws_read(&mut stream).await;
|
||||
if explicit_close {
|
||||
ws_send(
|
||||
&mut stream,
|
||||
"<close xmlns='urn:ietf:params:xml:ns:xmpp-framing'/>",
|
||||
)
|
||||
.await;
|
||||
// Follow the XMPP close immediately with a masked WebSocket close frame.
|
||||
stream.write_all(&[0x88, 0x80, 0, 0, 0, 0]).await.unwrap();
|
||||
}
|
||||
drop(stream);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), cleaned.notified())
|
||||
.await
|
||||
.expect("transport must let the engine finish cleanup");
|
||||
assert_eq!(
|
||||
outcome.load(Ordering::SeqCst),
|
||||
if explicit_close { 1 } else { 2 }
|
||||
);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_close_allows_graceful_engine_cleanup() {
|
||||
websocket_cleanup(true).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_disconnect_delivers_eof_without_graceful_close() {
|
||||
websocket_cleanup(false).await;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use rust_xmpp::http_service::{UploadConfig, UploadService};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
};
|
||||
async fn request(addr: std::net::SocketAddr, text: &str) -> String {
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
stream.write_all(text.as_bytes()).await.unwrap();
|
||||
let mut out = vec![];
|
||||
stream.read_to_end(&mut out).await.unwrap();
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn upload_authorization_streaming_ranges_and_restart() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let cfg = UploadConfig {
|
||||
root: temp.path().into(),
|
||||
host: "upload.example.test".into(),
|
||||
base_url: format!("http://{addr}/file_share"),
|
||||
size_limit: 10 * 1024 * 1024 * 1024,
|
||||
daily_quota: 100,
|
||||
global_quota: 100,
|
||||
expires_after: None,
|
||||
secret: Some("test-secret".into()),
|
||||
allowed_types: vec![],
|
||||
};
|
||||
let service = UploadService::new(cfg.clone()).unwrap();
|
||||
assert!(
|
||||
service
|
||||
.request_slot("a@example.test", "../secret", 5, "text/plain")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.request_slot("a@example.test", "foo\\bar", 5, "text/plain")
|
||||
.is_err()
|
||||
);
|
||||
let slot = service
|
||||
.request_slot("a@example.test", "hello.txt", 5, "text/plain")
|
||||
.unwrap();
|
||||
let path = slot
|
||||
.url
|
||||
.strip_prefix(&format!("http://{addr}"))
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let serving = service.clone();
|
||||
let task = tokio::spawn(async move { serving.serve(listener, None).await.unwrap() });
|
||||
let unauthorized=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")).await;
|
||||
assert!(unauthorized.starts_with("HTTP/1.1 403"), "{unauthorized}");
|
||||
let oversized=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 6\r\nConnection: close\r\n\r\nhello!",slot.authorization)).await;
|
||||
assert!(oversized.starts_with("HTTP/1.1 413"));
|
||||
let oversized_chunked=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n6\r\nhello!\r\n0\r\n\r\n",slot.authorization)).await;
|
||||
assert!(oversized_chunked.starts_with("HTTP/1.1 413"));
|
||||
let short_chunked=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n4\r\nhell\r\n0\r\n\r\n",slot.authorization)).await;
|
||||
assert!(short_chunked.starts_with("HTTP/1.1 400"));
|
||||
let authorization = slot.authorization.replacen("Bearer ", "bEaReR ", 1);
|
||||
let uploaded=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nhe\r\n3\r\nllo\r\n0\r\n\r\n",authorization)).await;
|
||||
assert!(uploaded.starts_with("HTTP/1.1 201"), "{uploaded}");
|
||||
let again=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello",slot.authorization)).await;
|
||||
assert!(again.starts_with("HTTP/1.1 409"));
|
||||
let get = request(
|
||||
addr,
|
||||
&format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
get.starts_with("HTTP/1.1 200") && get.ends_with("hello"),
|
||||
"{get}"
|
||||
);
|
||||
let range = request(
|
||||
addr,
|
||||
&format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {addr}\r\nRange: bytes=1-3\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(range.starts_with("HTTP/1.1 206") && range.ends_with("ell"));
|
||||
let beyond_eof = request(
|
||||
addr,
|
||||
&format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {addr}\r\nRange: bytes=1-999\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
beyond_eof.starts_with("HTTP/1.1 206") && beyond_eof.ends_with("ello"),
|
||||
"{beyond_eof}"
|
||||
);
|
||||
assert!(beyond_eof.contains("content-range: bytes 1-4/5"));
|
||||
let unsatisfiable = request(
|
||||
addr,
|
||||
&format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {addr}\r\nRange: bytes=5-999\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(unsatisfiable.starts_with("HTTP/1.1 416"));
|
||||
let traverse = request(
|
||||
addr,
|
||||
&format!("GET {path}/../../secret HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"),
|
||||
)
|
||||
.await;
|
||||
assert!(traverse.starts_with("HTTP/1.1 404"));
|
||||
assert!(
|
||||
service
|
||||
.request_slot("a@example.test", "more", 96, "text/plain")
|
||||
.is_err()
|
||||
);
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
let restarted = UploadService::new(cfg).unwrap();
|
||||
let l = TcpListener::bind(addr).await.unwrap();
|
||||
let task = tokio::spawn(async move { restarted.serve(l, None).await.unwrap() });
|
||||
let get = request(
|
||||
addr,
|
||||
&format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"),
|
||||
)
|
||||
.await;
|
||||
assert!(get.ends_with("hello"));
|
||||
task.abort();
|
||||
}
|
||||
#[test]
|
||||
fn expired_upload_releases_quota() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cfg = UploadConfig {
|
||||
root: temp.path().into(),
|
||||
host: "upload.example.test".into(),
|
||||
base_url: "https://example.test/file_share".into(),
|
||||
size_limit: 10,
|
||||
daily_quota: 10,
|
||||
global_quota: 10,
|
||||
expires_after: Some(0),
|
||||
secret: None,
|
||||
allowed_types: vec!["image/*".into()],
|
||||
};
|
||||
let s = UploadService::new(cfg).unwrap();
|
||||
assert!(
|
||||
s.request_slot("a@example.test", "x", 10, "text/plain")
|
||||
.is_err()
|
||||
);
|
||||
s.request_slot("a@example.test", "x", 10, "image/png")
|
||||
.unwrap();
|
||||
s.request_slot("a@example.test", "x", 10, "image/png")
|
||||
.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn persisted_prosody_archive_and_hs256_slot_survive_without_sidecar() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let config = UploadConfig {
|
||||
root: temp.path().into(),
|
||||
host: "upload.example.test".into(),
|
||||
base_url: format!("http://{addr}/file_share"),
|
||||
size_limit: 1024,
|
||||
daily_quota: 1024,
|
||||
global_quota: 1024,
|
||||
expires_after: None,
|
||||
secret: Some("shared-prosody-secret".into()),
|
||||
allowed_types: vec![],
|
||||
};
|
||||
let service = UploadService::new(config.clone()).unwrap();
|
||||
let slot = service
|
||||
.request_slot("a@example.test", "legacy.txt", 4, "text/plain")
|
||||
.unwrap();
|
||||
drop(service);
|
||||
std::fs::remove_file(
|
||||
temp.path()
|
||||
.join(rust_xmpp::storage::encode_name("upload.example.test"))
|
||||
.join("rust_uploads.json"),
|
||||
)
|
||||
.unwrap();
|
||||
let service = UploadService::new(config).unwrap();
|
||||
let task = tokio::spawn(async move { service.serve(listener, None).await.unwrap() });
|
||||
let path = slot.url.strip_prefix(&format!("http://{addr}")).unwrap();
|
||||
let result=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata",slot.authorization)).await;
|
||||
assert!(result.starts_with("HTTP/1.1 201"), "{result}");
|
||||
task.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn https_endpoint_uses_real_tls() {
|
||||
use std::io::BufReader;
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor, TlsConnector,
|
||||
rustls::{ClientConfig, RootCertStore, ServerConfig, pki_types::ServerName},
|
||||
};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cert = temp.path().join("cert.pem");
|
||||
let key = temp.path().join("key.pem");
|
||||
let output = std::process::Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"ec",
|
||||
"-pkeyopt",
|
||||
"ec_paramgen_curve:P-256",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-keyout",
|
||||
])
|
||||
.arg(&key)
|
||||
.arg("-out")
|
||||
.arg(&cert)
|
||||
.output()
|
||||
.expect("openssl required for TLS integration test");
|
||||
assert!(output.status.success());
|
||||
let certs = rustls_pemfile::certs(&mut BufReader::new(std::fs::File::open(cert).unwrap()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
let key = rustls_pemfile::private_key(&mut BufReader::new(std::fs::File::open(key).unwrap()))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let server = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs.clone(), key)
|
||||
.unwrap();
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(certs[0].clone()).unwrap();
|
||||
let client = ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let cfg = UploadConfig {
|
||||
root: temp.path().into(),
|
||||
host: "localhost".into(),
|
||||
base_url: format!("https://localhost:{}/file_share", addr.port()),
|
||||
size_limit: 1024,
|
||||
daily_quota: 1024,
|
||||
global_quota: 1024,
|
||||
expires_after: None,
|
||||
secret: None,
|
||||
allowed_types: vec![],
|
||||
};
|
||||
let s = UploadService::new(cfg).unwrap();
|
||||
let task = tokio::spawn(async move {
|
||||
s.serve(
|
||||
listener,
|
||||
Some(TlsAcceptor::from(std::sync::Arc::new(server))),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
let socket = TcpStream::connect(addr).await.unwrap();
|
||||
let mut tls = TlsConnector::from(std::sync::Arc::new(client))
|
||||
.connect(ServerName::try_from("localhost").unwrap(), socket)
|
||||
.await
|
||||
.unwrap();
|
||||
tls.write_all(
|
||||
b"GET /file_share/missing/name HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut result = String::new();
|
||||
tls.read_to_string(&mut result).await.unwrap();
|
||||
assert!(result.starts_with("HTTP/1.1 404"));
|
||||
task.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn percent_escape_case_and_equivalent_url_spelling() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let config = UploadConfig {
|
||||
root: temp.path().into(),
|
||||
host: "upload.example.test".into(),
|
||||
base_url: format!("http://{addr}/file_share"),
|
||||
size_limit: 10,
|
||||
daily_quota: 100,
|
||||
global_quota: 100,
|
||||
expires_after: None,
|
||||
secret: None,
|
||||
allowed_types: vec![],
|
||||
};
|
||||
let service = UploadService::new(config).unwrap();
|
||||
let slot = service
|
||||
.request_slot("a@example.test", "фото+1.txt", 4, "text/plain")
|
||||
.unwrap();
|
||||
let path = slot
|
||||
.url
|
||||
.strip_prefix(&format!("http://{addr}"))
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let task = tokio::spawn(async move { service.serve(listener, None).await.unwrap() });
|
||||
let authorization = slot.authorization.replacen("Bearer ", "bearer ", 1);
|
||||
let put=request(addr,&format!("PUT {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {}\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata",authorization)).await;
|
||||
assert!(put.starts_with("HTTP/1.1 201"));
|
||||
let alternate = path.to_lowercase().replace("%2b", "+");
|
||||
let get = request(
|
||||
addr,
|
||||
&format!("GET {alternate} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"),
|
||||
)
|
||||
.await;
|
||||
assert!(get.ends_with("data"), "{get}");
|
||||
task.abort();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use rust_xmpp::protocol;
|
||||
|
||||
#[test]
|
||||
fn pep_errors_identify_the_account_and_echo_the_request() {
|
||||
let doc = roxmltree::Document::parse("<iq id='bundle' type='get' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='eu.siacs.conversations.axolotl.bundles:123'/></pubsub></iq>").unwrap();
|
||||
let reply = protocol::iq_error(doc.root_element(), "item-not-found");
|
||||
let parsed = roxmltree::Document::parse(&reply).unwrap();
|
||||
let iq = parsed.root_element();
|
||||
assert_eq!(iq.attribute("from"), Some("alice@example.test"));
|
||||
assert_eq!(iq.attribute("id"), Some("bundle"));
|
||||
assert!(
|
||||
iq.first_element_child()
|
||||
.unwrap()
|
||||
.has_tag_name(("http://jabber.org/protocol/pubsub", "pubsub"))
|
||||
);
|
||||
assert!(
|
||||
iq.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "item-not-found")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_errors_without_target_remain_unaddressed_and_escape_attributes() {
|
||||
for target in ["", " to='alice&bob@example.test'"] {
|
||||
let request = format!("<iq id='a&b' type='get'{target}><query xmlns='urn:test'/></iq>");
|
||||
let doc = roxmltree::Document::parse(&request).unwrap();
|
||||
let reply = protocol::iq_error(doc.root_element(), "service-unavailable");
|
||||
let parsed = roxmltree::Document::parse(&reply).unwrap();
|
||||
assert_eq!(
|
||||
parsed.root_element().attribute("from"),
|
||||
doc.root_element().attribute("to")
|
||||
);
|
||||
assert_eq!(parsed.root_element().attribute("id"), Some("a&b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use rust_xmpp::logging::{Level, Logger};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn filters_appends_escapes_and_redacts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("events.log");
|
||||
std::fs::write(&path, "existing\n").unwrap();
|
||||
let logger = Logger::from_config(Some(&json!({"info":path.to_str().unwrap()}))).unwrap();
|
||||
logger.event(Level::Debug, "test", None, "hidden", &[]);
|
||||
logger.event(
|
||||
Level::Info,
|
||||
"test\nmodule",
|
||||
Some("c1"),
|
||||
"connected",
|
||||
&[
|
||||
("password", "supersecret"),
|
||||
("access_token", "token-secret"),
|
||||
("raw_xml", "<auth>secret</auth>"),
|
||||
("peer", "a\n\r\u{1b}\"b"),
|
||||
],
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.starts_with("existing\n"));
|
||||
assert_eq!(text.lines().count(), 2);
|
||||
assert!(!text.contains("hidden"));
|
||||
assert!(!text.contains("supersecret"));
|
||||
assert!(!text.contains("token-secret"));
|
||||
assert!(!text.contains("<auth>"));
|
||||
assert!(text.contains("[REDACTED]"));
|
||||
assert!(text.contains("test\\nmodule"));
|
||||
assert!(text.contains("\\u001b"));
|
||||
assert!(text.contains("level=info"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_records_and_bounded_ranges() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("debug.log");
|
||||
let config = json!([{"levels":{"min":"debug","max":"info"},"to":"file","filename":path}]);
|
||||
let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap();
|
||||
logger.event(Level::Error, "net", None, "excluded", &[]);
|
||||
logger.event(
|
||||
Level::Debug,
|
||||
"net",
|
||||
Some("42"),
|
||||
"accepted",
|
||||
&[("client_proof", "secret"), ("peer", "localhost")],
|
||||
);
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
let record: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
|
||||
assert_eq!(record["level"], "debug");
|
||||
assert_eq!(record["connection"], "42");
|
||||
assert_eq!(record["fields"][0]["value"], "[REDACTED]");
|
||||
let timestamp = record["timestamp"].as_str().unwrap();
|
||||
assert!(timestamp.ends_with('Z'));
|
||||
assert_eq!(timestamp.len(), 24);
|
||||
chrono::DateTime::parse_from_rfc3339(timestamp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_loggers_serialize_records() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("threads.log");
|
||||
let logger = Logger::from_config(Some(&json!({"debug":path}))).unwrap();
|
||||
let handles: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let logger = logger.clone();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..40 {
|
||||
logger.event(
|
||||
Level::Info,
|
||||
"worker",
|
||||
None,
|
||||
"complete",
|
||||
&[("text", "x".repeat(4096).as_str())],
|
||||
);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
assert_eq!(text.lines().count(), 160);
|
||||
assert!(
|
||||
text.lines()
|
||||
.all(|line| line.starts_with("timestamp=") && line.ends_with('"'))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_configuration() {
|
||||
for config in [
|
||||
json!(42),
|
||||
json!({"bogus":"*console"}),
|
||||
json!({"info":42}),
|
||||
json!("*unknown"),
|
||||
json!([{"to":"file"}]),
|
||||
json!([{"to":"console","levels":{"min":"error","max":"debug"}}]),
|
||||
] {
|
||||
assert!(
|
||||
Logger::from_config(Some(&config)).is_err(),
|
||||
"accepted {config}"
|
||||
);
|
||||
}
|
||||
assert!(Logger::from_config(None).is_ok());
|
||||
assert!(Logger::from_config(Some(&json!("debug"))).is_ok());
|
||||
assert!(Logger::from_config(Some(&json!("*console"))).is_ok());
|
||||
assert!(Logger::from_config_with_format(None, Some("invalid")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_sink_levels_select_exactly_and_deduplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (name, selection, expected) in [
|
||||
("single", json!("warn"), vec!["warn"]),
|
||||
(
|
||||
"set",
|
||||
json!(["debug", "error", "debug"]),
|
||||
vec!["debug", "error"],
|
||||
),
|
||||
(
|
||||
"all",
|
||||
serde_json::Value::Null,
|
||||
vec!["debug", "info", "warn", "error"],
|
||||
),
|
||||
] {
|
||||
let path = dir.path().join(name);
|
||||
let mut config = json!({"to":"file", "filename":path});
|
||||
if !selection.is_null() {
|
||||
config["levels"] = selection;
|
||||
}
|
||||
let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap();
|
||||
for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] {
|
||||
logger.event(level, "test", None, "selection", &[]);
|
||||
}
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
let actual: Vec<String> = text
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let record: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
record["level"].as_str().unwrap().to_owned()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, expected, "selection {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_console_shorthands_are_accepted() {
|
||||
assert!(Logger::from_config(Some(&json!("*console"))).is_ok());
|
||||
assert!(Logger::from_config(Some(&json!(["*console"]))).is_ok());
|
||||
assert!(Logger::from_config(Some(&json!(["*unknown"]))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_shorthand_destinations_receive_each_record_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("shared.log");
|
||||
let config = json!({"debug":path,"info":path,"error":path});
|
||||
let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap();
|
||||
for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] {
|
||||
logger.event(level, "test", None, "selection", &[]);
|
||||
}
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
let levels: Vec<String> = text
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let record: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
record["level"].as_str().unwrap().to_owned()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(levels, ["debug", "info", "warn", "error"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_sink_unions_levels_and_preserves_distinct_destinations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shared = dir.path().join("shared.log");
|
||||
let other = dir.path().join("other.log");
|
||||
let config = json!([
|
||||
{"to":"file","filename":shared,"levels":["debug","error"]},
|
||||
{"to":"file","filename":shared,"levels":["warn","error"]},
|
||||
{"to":"file","filename":other,"levels":{"min":"info","max":"error"}}
|
||||
]);
|
||||
let logger = Logger::from_config_with_format(Some(&config), Some("json")).unwrap();
|
||||
for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] {
|
||||
logger.event(level, "test", None, "selection", &[]);
|
||||
}
|
||||
for (path, expected) in [
|
||||
(shared, vec!["debug", "warn", "error"]),
|
||||
(other, vec!["info", "warn", "error"]),
|
||||
] {
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
let levels: Vec<String> = text
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let record: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
record["level"].as_str().unwrap().to_owned()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(levels, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_rotated_files_updates_clones() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("events.log");
|
||||
let rotated = dir.path().join("events.log.1");
|
||||
let logger = Logger::from_config(Some(&json!({"info":path}))).unwrap();
|
||||
let clone = logger.clone();
|
||||
logger.event(Level::Info, "test", None, "before_rotation", &[]);
|
||||
std::fs::rename(&path, &rotated).unwrap();
|
||||
logger.reopen().unwrap();
|
||||
clone.event(Level::Info, "test", None, "after_rotation", &[]);
|
||||
let old = std::fs::read_to_string(rotated).unwrap();
|
||||
let new = std::fs::read_to_string(path).unwrap();
|
||||
assert!(old.contains("before_rotation"));
|
||||
assert!(!old.contains("after_rotation"));
|
||||
assert!(new.contains("after_rotation"));
|
||||
assert!(!new.contains("before_rotation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_reopen_keeps_all_original_handles() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join("first.log");
|
||||
let second = dir.path().join("second.log");
|
||||
let first_old = dir.path().join("first.log.1");
|
||||
let second_old = dir.path().join("second.log.1");
|
||||
let logger = Logger::from_config(Some(&json!([
|
||||
{"to":"file","filename":first},
|
||||
{"to":"file","filename":second}
|
||||
])))
|
||||
.unwrap();
|
||||
std::fs::rename(&first, &first_old).unwrap();
|
||||
std::fs::rename(&second, &second_old).unwrap();
|
||||
std::fs::create_dir(&second).unwrap();
|
||||
assert!(logger.reopen().is_err());
|
||||
logger.event(Level::Info, "test", None, "retained_handles", &[]);
|
||||
assert!(
|
||||
std::fs::read_to_string(first_old)
|
||||
.unwrap()
|
||||
.contains("retained_handles")
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_to_string(second_old)
|
||||
.unwrap()
|
||||
.contains("retained_handles")
|
||||
);
|
||||
assert!(std::fs::read_to_string(first).unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structured logging regression using isolated accounts and real STARTTLS.
|
||||
|
||||
Usage: python3 tests/logging_network.py [BINARY]
|
||||
Only a short PASS is printed: server output and credential-bearing wire data
|
||||
remain in the temporary fixture and are never included in assertion messages.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import signal
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from _wire_client import BIN, Client
|
||||
from android_sasl_network import Connection, SASL, b64
|
||||
|
||||
PASSWORD = 'logging-fixture-password-7d092c'
|
||||
WRONG_PASSWORD = 'logging-wrong-password-8f14ad'
|
||||
BODY = 'logging-private-message-body-326abc'
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def records(path):
|
||||
if not path.exists():
|
||||
return []
|
||||
result = []
|
||||
for line in path.read_text().splitlines():
|
||||
try:
|
||||
result.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
raise AssertionError('log file contains a non-JSON record') from None
|
||||
return result
|
||||
|
||||
|
||||
def wait_for(path, predicate):
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
found = records(path)
|
||||
if predicate(found):
|
||||
return found
|
||||
time.sleep(.02)
|
||||
raise AssertionError('required structured log event was not written')
|
||||
|
||||
|
||||
def fields(record):
|
||||
values = record['fields']
|
||||
require(isinstance(values, list), 'fields must be a list')
|
||||
require(all(isinstance(f, dict) and set(f) == {'key', 'value'} for f in values),
|
||||
'invalid structured field shape')
|
||||
return {f['key']: f['value'] for f in values}
|
||||
|
||||
|
||||
def plain(port, cert, user, password, forbidden):
|
||||
connection = Connection(port, cert, ssl.TLSVersion.TLSv1_2)
|
||||
try:
|
||||
payload = b64(f'\0{user}\0{password}'.encode())
|
||||
forbidden.add(payload)
|
||||
connection.send(f"<auth xmlns='{SASL}' mechanism='PLAIN'>{payload}</auth>")
|
||||
require(connection.stanza().tag == '{' + SASL + '}failure',
|
||||
'invalid PLAIN login was accepted')
|
||||
finally:
|
||||
connection.sock.close()
|
||||
|
||||
|
||||
def corrupt_account(port, cert, forbidden):
|
||||
connection = Connection(port, cert, ssl.TLSVersion.TLSv1_2)
|
||||
try:
|
||||
payload = b64(b'n,,n=broken,r=corrupt-account-log-check')
|
||||
forbidden.add(payload)
|
||||
connection.send(f"<auth xmlns='{SASL}' mechanism='SCRAM-SHA-256'>{payload}</auth>")
|
||||
require(connection.stanza().tag == '{' + SASL + '}failure',
|
||||
'corrupt account record did not reject authentication')
|
||||
finally:
|
||||
connection.sock.close()
|
||||
|
||||
|
||||
def scram(port, cert, forbidden, wrong_binding=False, disconnect=False):
|
||||
connection = Connection(port, cert, ssl.TLSVersion.TLSv1_2)
|
||||
try:
|
||||
gs2 = 'p=tls-server-end-point,,'
|
||||
bare = 'n=alice,r=log-fixture-client-nonce'
|
||||
initial = b64((gs2 + bare).encode())
|
||||
forbidden.add(initial)
|
||||
connection.send(f"<auth xmlns='{SASL}' mechanism='SCRAM-SHA-256-PLUS'>{initial}</auth>")
|
||||
challenge = connection.stanza()
|
||||
require(challenge.tag == '{' + SASL + '}challenge', 'SCRAM challenge missing')
|
||||
if disconnect:
|
||||
return
|
||||
first = base64.b64decode(challenge.text).decode()
|
||||
attrs = dict(item.split('=', 1) for item in first.split(','))
|
||||
binding = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).digest()
|
||||
if wrong_binding:
|
||||
binding = bytes([binding[0] ^ 1]) + binding[1:]
|
||||
final = 'c=' + b64(gs2.encode() + binding) + ',r=' + attrs['r']
|
||||
salted = hashlib.pbkdf2_hmac('sha256', PASSWORD.encode(),
|
||||
base64.b64decode(attrs['s']), int(attrs['i']))
|
||||
mac = lambda key, message: hmac.new(key, message, 'sha256').digest()
|
||||
client_key = mac(salted, b'Client Key')
|
||||
signature = mac(hashlib.sha256(client_key).digest(),
|
||||
(bare + ',' + first + ',' + final).encode())
|
||||
proof = bytes(a ^ b for a, b in zip(client_key, signature))
|
||||
if not wrong_binding:
|
||||
proof = bytes([proof[0] ^ 1]) + proof[1:]
|
||||
encoded_proof = b64(proof)
|
||||
response = final + ',p=' + encoded_proof
|
||||
payload = b64(response.encode())
|
||||
forbidden.update((encoded_proof, response, payload))
|
||||
connection.send(f"<response xmlns='{SASL}'>{payload}</response>")
|
||||
require(connection.stanza().tag == '{' + SASL + '}failure',
|
||||
'invalid SCRAM response was accepted')
|
||||
finally:
|
||||
connection.sock.close()
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-logging-') as tmp:
|
||||
root = Path(tmp)
|
||||
cert = root / 'cert.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||
'-sha256', '-nodes', '-days', '1', '-keyout', str(root / 'key.pem'),
|
||||
'-out', str(cert), '-subj', '/CN=localhost',
|
||||
'-addext', 'subjectAltName=DNS:localhost'], check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
log_path = root / 'structured.jsonl'
|
||||
config = root / 'config.json'
|
||||
config.write_text(json.dumps({'global': {
|
||||
'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
|
||||
'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [],
|
||||
'modules_enabled': ['tls', 'saslauth', 'ping'],
|
||||
'log_format': 'json', 'log': {'debug': str(log_path)}},
|
||||
'hosts': {'localhost': {'options': {
|
||||
'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
||||
'ssl': {'key': str(root / 'key.pem'), 'certificate': str(cert)}}}}}))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
|
||||
input=PASSWORD + '\n', text=True, check=True, capture_output=True)
|
||||
account_dir = root / 'data' / 'localhost' / 'accounts'
|
||||
require((account_dir / 'alice.dat').exists(), 'fixture account path missing')
|
||||
(account_dir / 'broken.dat').write_text('return { malformed =\n')
|
||||
forbidden = {PASSWORD, WRONG_PASSWORD, BODY,
|
||||
b64(f'\0alice\0{PASSWORD}'.encode())}
|
||||
start = datetime.now(timezone.utc)
|
||||
with (root / 'process.log').open('w') as output:
|
||||
process = subprocess.Popen([binary, 'serve', str(config), '--experimental'],
|
||||
stdout=output, stderr=output)
|
||||
try:
|
||||
for _ in range(100):
|
||||
require(process.poll() is None, 'fixture server stopped')
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('fixture server did not listen')
|
||||
# Wait for readiness-probe close before measuring individual sessions.
|
||||
wait_for(log_path, lambda rows: any(r.get('event') == 'connection-closed' for r in rows))
|
||||
cases = []
|
||||
|
||||
def case(name, action, expected, rejected=False, reason=None):
|
||||
before = {r.get('connection') for r in records(log_path)
|
||||
if r.get('event') == 'connection-open'}
|
||||
action()
|
||||
rows = wait_for(log_path, lambda rs: any(
|
||||
r.get('event') == 'connection-closed' and r.get('connection') not in before
|
||||
for r in rs))
|
||||
opens = [r for r in rows if r.get('event') == 'connection-open'
|
||||
and r.get('connection') not in before]
|
||||
require(len(opens) == 1, name + ': expected one connection ID')
|
||||
cid = opens[0]['connection']
|
||||
require(isinstance(cid, str) and bool(cid), name + ': missing connection ID')
|
||||
session = [r for r in rows if r.get('connection') == cid]
|
||||
events = [r['event'] for r in session]
|
||||
cursor = 0
|
||||
for event in expected:
|
||||
require(event in events[cursor:], name + ': missing or out-of-order ' + event)
|
||||
cursor = events.index(event, cursor) + 1
|
||||
if rejected:
|
||||
rejections = [r for r in session if r['event'] == 'sasl-rejected']
|
||||
require(bool(rejections), name + ': missing rejection')
|
||||
reasons = [fields(r).get('reason') for r in rejections]
|
||||
require(all(isinstance(r, str) and r for r in reasons), name + ': reason missing')
|
||||
if reason:
|
||||
lookup = [r for r in session if r['event'] == 'account-lookup']
|
||||
require(bool(lookup), name + ': missing account lookup diagnostic')
|
||||
require(any(fields(r).get('reason') in reason for r in lookup),
|
||||
name + ': expected account lookup reason missing')
|
||||
require('auth-success' not in events and 'resource-bound' not in events,
|
||||
name + ': rejected session authenticated')
|
||||
cases.append(cid)
|
||||
|
||||
def success():
|
||||
client = Client(port, cert, 'alice', PASSWORD, resource='logging-check')
|
||||
try:
|
||||
client.sync()
|
||||
client.send(f"<message to='alice@localhost/logging-check'><body>{BODY}</body></message>")
|
||||
client.sync()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
common = ['connection-open', 'tls-established', 'sasl-features', 'sasl-attempt']
|
||||
case('PLAIN success', success, common + ['auth-success', 'resource-bound', 'connection-closed'])
|
||||
case('PLAIN wrong password', lambda: plain(port, cert, 'alice', WRONG_PASSWORD, forbidden),
|
||||
common + ['sasl-rejected', 'connection-closed'], rejected=True)
|
||||
for wrong_binding in (False, True):
|
||||
case('SCRAM invalid binding' if wrong_binding else 'SCRAM wrong proof',
|
||||
lambda wrong_binding=wrong_binding: scram(port, cert, forbidden, wrong_binding),
|
||||
common + ['sasl-challenge', 'sasl-rejected', 'connection-closed'], rejected=True)
|
||||
case('disconnect during challenge', lambda: scram(port, cert, forbidden, disconnect=True),
|
||||
common + ['sasl-challenge', 'connection-closed'])
|
||||
case('unknown user', lambda: plain(port, cert, 'missing-user', WRONG_PASSWORD, forbidden),
|
||||
common + ['sasl-rejected', 'connection-closed'], rejected=True)
|
||||
case('corrupt account', lambda: corrupt_account(port, cert, forbidden),
|
||||
common + ['sasl-rejected', 'connection-closed'], rejected=True, reason={'account-record-invalid', 'account-hash-or-record-invalid'})
|
||||
rotated = root / 'structured-rotated.jsonl'
|
||||
log_path.rename(rotated)
|
||||
process.send_signal(signal.SIGHUP)
|
||||
wait_for(log_path, lambda rs: any(r.get('event') == 'logs-reopened' for r in rs))
|
||||
case('PLAIN success after SIGHUP rotation', success,
|
||||
common + ['auth-success', 'resource-bound', 'connection-closed'])
|
||||
require(len(set(cases)) == len(cases), 'connection IDs were reused')
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
end = datetime.now(timezone.utc)
|
||||
rows = records(rotated) + records(log_path)
|
||||
require(bool(rows), 'configured JSON log file is empty')
|
||||
previous = start
|
||||
for row in rows:
|
||||
require(set(row) == {'timestamp', 'level', 'module', 'connection', 'event', 'fields'},
|
||||
'invalid JSON log record shape')
|
||||
require(row['level'] in ('debug', 'info', 'warn', 'error'), 'invalid log level')
|
||||
require(isinstance(row['module'], str) and row['module'], 'missing module')
|
||||
require(isinstance(row['event'], str) and row['event'], 'missing event')
|
||||
fields(row)
|
||||
try:
|
||||
stamp = datetime.fromisoformat(row['timestamp'].replace('Z', '+00:00'))
|
||||
except (TypeError, ValueError):
|
||||
raise AssertionError('invalid log timestamp') from None
|
||||
require(stamp.tzinfo is not None, 'log timestamp lacks timezone')
|
||||
# Logger timestamps use millisecond precision.
|
||||
require(start.timestamp() - .001 <= stamp.timestamp() <= end.timestamp(),
|
||||
'log timestamp is outside fixture lifetime')
|
||||
require(stamp.timestamp() + .001 >= previous.timestamp(), 'log timestamps are out of order')
|
||||
previous = stamp
|
||||
raw = rotated.read_text() + log_path.read_text()
|
||||
require(all(value not in raw for value in forbidden), 'sensitive wire content leaked into log')
|
||||
print('PASS: JSON file logging, TLS/auth/bind correlation, rejection reasons, secret redaction and SIGHUP rotation')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()) if len(sys.argv) > 1 else str(BIN))
|
||||
@@ -0,0 +1,76 @@
|
||||
use rust_xmpp::sessions::Hub;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn unknown_resource_falls_back_to_available_bare_jid() {
|
||||
let hub = Hub::default();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let s = hub
|
||||
.bind("bob@example.test/phone", "c", tx, Duration::from_secs(30))
|
||||
.unwrap();
|
||||
s.lock().unwrap().available = true;
|
||||
let targets = hub.message_targets("bob@example.test/old-resource");
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].lock().unwrap().jid, "bob@example.test/phone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headline_uses_all_nonnegative_available_resources_and_errors_never_fallback() {
|
||||
let hub = Hub::default();
|
||||
let mut receivers = vec![];
|
||||
for (resource, available, priority) in [
|
||||
("high", true, 5),
|
||||
("low", true, 1),
|
||||
("negative", true, -1),
|
||||
("unavailable", false, 10),
|
||||
] {
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
receivers.push(rx);
|
||||
let s = hub
|
||||
.bind(
|
||||
&format!("bob@example.test/{resource}"),
|
||||
resource,
|
||||
tx,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = s.lock().unwrap();
|
||||
state.available = available;
|
||||
state.priority = priority;
|
||||
}
|
||||
let names = |to: &str, kind: &str| {
|
||||
let mut names = hub
|
||||
.message_targets_for_type(to, kind)
|
||||
.iter()
|
||||
.map(|s| s.lock().unwrap().jid.clone())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
assert_eq!(
|
||||
names("bob@example.test", "normal"),
|
||||
["bob@example.test/high"]
|
||||
);
|
||||
assert_eq!(
|
||||
names("bob@example.test", "headline"),
|
||||
["bob@example.test/high", "bob@example.test/low"]
|
||||
);
|
||||
assert_eq!(
|
||||
names("bob@example.test/negative", "headline"),
|
||||
["bob@example.test/negative"]
|
||||
);
|
||||
assert_eq!(
|
||||
names("bob@example.test/absent", "chat"),
|
||||
["bob@example.test/high"]
|
||||
);
|
||||
for kind in ["error", "groupchat"] {
|
||||
assert!(names("bob@example.test", kind).is_empty());
|
||||
assert!(names("bob@example.test/absent", kind).is_empty());
|
||||
assert_eq!(
|
||||
names("bob@example.test/low", kind),
|
||||
["bob@example.test/low"]
|
||||
);
|
||||
}
|
||||
assert!(names("bob@example.test/absent", "headline").is_empty());
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use rust_xmpp::{
|
||||
muc::Muc,
|
||||
protocol::ModuleResult,
|
||||
storage::{self, LuaValue as V},
|
||||
};
|
||||
|
||||
fn send(muc: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(packet).unwrap();
|
||||
muc.handle(jid, doc.root_element()).unwrap().unwrap()
|
||||
}
|
||||
|
||||
fn join(muc: &mut Muc, jid: &str, nick: &str) -> ModuleResult {
|
||||
send(muc, jid, &format!("<presence to='r@rooms.test/{nick}'/>"))
|
||||
}
|
||||
|
||||
fn role(muc: &mut Muc, actor: &str, nick: &str, role: &str) -> ModuleResult {
|
||||
send(
|
||||
muc,
|
||||
actor,
|
||||
&format!(
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='{nick}' role='{role}'/></query></iq>"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderators_cannot_kick_or_demote_other_moderators() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
|
||||
join(&mut muc, "owner@test/r", "Owner");
|
||||
join(&mut muc, "bob@test/r", "Bob");
|
||||
join(&mut muc, "carol@test/r", "Carol");
|
||||
for nick in ["Bob", "Carol"] {
|
||||
assert!(
|
||||
role(&mut muc, "owner@test/r", nick, "moderator").replies[0].contains("type='result'")
|
||||
);
|
||||
}
|
||||
for denied_role in ["none", "visitor", "participant"] {
|
||||
let result = role(&mut muc, "bob@test/r", "Carol", denied_role);
|
||||
assert!(result.replies[0].contains("not-allowed"));
|
||||
assert!(result.deliveries.is_empty());
|
||||
}
|
||||
// An owner can still revoke an unaffiliated moderator's role.
|
||||
let result = role(&mut muc, "owner@test/r", "Carol", "participant");
|
||||
assert!(result.replies[0].contains("type='result'"));
|
||||
assert!(
|
||||
result
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(jid, packet)| jid == "carol@test/r" && packet.contains("role='participant'"))
|
||||
);
|
||||
assert!(role(&mut muc, "carol@test/r", "Bob", "none").replies[0].contains("forbidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_occupant_id_salt_survives_saves_and_restarts() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let salt = V::String(vec![0xff, 0, 0x80, b's']);
|
||||
let mut raw = storage::parse(
|
||||
b"return {_jid='r@rooms.test'; _data={persistent=true}; ['owner@test']='owner'}",
|
||||
)
|
||||
.unwrap();
|
||||
let mut data = raw.get("_data").unwrap().clone();
|
||||
data.set("occupant_id_salt", salt.clone());
|
||||
raw.set("_data", data);
|
||||
storage::save(temp.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
|
||||
let result = join(&mut muc, "owner@test/r", "Owner");
|
||||
let packet = result
|
||||
.replies
|
||||
.iter()
|
||||
.find(|p| p.starts_with("<presence"))
|
||||
.unwrap();
|
||||
let doc = roxmltree::Document::parse(packet).unwrap();
|
||||
let id = doc
|
||||
.descendants()
|
||||
.find(|n| n.has_tag_name(("urn:xmpp:occupant-id:0", "occupant-id")))
|
||||
.unwrap()
|
||||
.attribute("id")
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
ids.push(id);
|
||||
let saved = storage::load(temp.path(), "rooms.test", Some("r"), "config")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
saved.get("_data").unwrap().get("occupant_id_salt"),
|
||||
Some(&salt)
|
||||
);
|
||||
}
|
||||
assert_eq!(ids[0], ids[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_affiliations_do_not_fall_back_to_obsolete_flat_entries() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let raw = storage::parse(b"return {_jid='r@rooms.test'; _data={persistent=true; members_only=true}; _affiliations={['owner@test']='owner'; ['member@test']='member'}; ['member@test']='owner'}").unwrap();
|
||||
storage::save(temp.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
|
||||
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
|
||||
join(&mut muc, "owner@test/r", "Owner");
|
||||
let result = send(
|
||||
&mut muc,
|
||||
"owner@test/r",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item jid='member@test' affiliation='none'/></query></iq>",
|
||||
);
|
||||
assert!(result.replies[0].contains("type='result'"));
|
||||
assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required"));
|
||||
drop(muc);
|
||||
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
|
||||
assert!(join(&mut muc, "member@test/r", "Member").replies[0].contains("registration-required"));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage};
|
||||
|
||||
fn send(muc: &mut Muc, jid: &str, xml: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
muc.handle(jid, doc.root_element()).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_domain_bans_block_remote_users_but_allow_explicit_members() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
// Prosody freezes room affiliations into the top-level room table.
|
||||
let raw = storage::parse(b"return {_jid='room@rooms.test'; _data={persistent=true}; ['owner@test']='owner'; ['remote.test']='outcast'; ['member@remote.test']='member'}").unwrap();
|
||||
storage::save(temp.path(), "rooms.test", Some("room"), "config", &raw).unwrap();
|
||||
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
|
||||
let denied = send(
|
||||
&mut muc,
|
||||
"outsider@remote.test/client",
|
||||
"<presence to='room@rooms.test/Outsider'/>",
|
||||
);
|
||||
assert!(
|
||||
denied.replies[0].contains("forbidden"),
|
||||
"{:?}",
|
||||
denied.replies
|
||||
);
|
||||
let admitted = send(
|
||||
&mut muc,
|
||||
"member@remote.test/client",
|
||||
"<presence to='room@rooms.test/Member'/>",
|
||||
);
|
||||
assert!(!admitted.replies.iter().any(|p| p.contains("type='error'")));
|
||||
assert!(
|
||||
admitted
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("affiliation='member'"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_domain_ban_removes_remote_occupants_and_blocks_rejoin() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut muc = Muc::new(temp.path().into(), "rooms.test".into());
|
||||
send(
|
||||
&mut muc,
|
||||
"owner@test/r",
|
||||
"<presence to='room@rooms.test/Owner'/>",
|
||||
);
|
||||
send(
|
||||
&mut muc,
|
||||
"remote@remote.test/r",
|
||||
"<presence to='room@rooms.test/Remote'/>",
|
||||
);
|
||||
send(
|
||||
&mut muc,
|
||||
"other@allowed.test/r",
|
||||
"<presence to='room@rooms.test/Other'/>",
|
||||
);
|
||||
let banned = send(
|
||||
&mut muc,
|
||||
"owner@test/r",
|
||||
"<iq type='set' to='room@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item jid='remote.test' affiliation='outcast'/></query></iq>",
|
||||
);
|
||||
assert!(banned.replies[0].contains("type='result'"));
|
||||
assert!(
|
||||
banned
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(jid, packet)| jid == "remote@remote.test/r" && packet.contains("code='301'"))
|
||||
);
|
||||
let message = "<message type='groupchat' to='room@rooms.test'><body>hello</body></message>";
|
||||
assert!(send(&mut muc, "remote@remote.test/r", message).replies[0].contains("not-acceptable"));
|
||||
assert_eq!(
|
||||
send(&mut muc, "other@allowed.test/r", message)
|
||||
.deliveries
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
send(
|
||||
&mut muc,
|
||||
"remote@remote.test/new",
|
||||
"<presence to='room@rooms.test/Remote'/>"
|
||||
)
|
||||
.replies[0]
|
||||
.contains("forbidden")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
|
||||
use serde_json::json;
|
||||
const ROOM: &str = "room@rooms.example.org";
|
||||
fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(packet).unwrap();
|
||||
m.handle(jid, doc.root_element()).unwrap().unwrap()
|
||||
}
|
||||
fn setup(defaults: serde_json::Value) -> (tempfile::TempDir, Muc) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_defaults(defaults);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
);
|
||||
(root, m)
|
||||
}
|
||||
#[test]
|
||||
fn restricted_private_messages_allow_self_and_optional_moderators() {
|
||||
let (_root, mut m) = setup(json!({"allow_pm":"none"}));
|
||||
send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
let denied = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<message to='{ROOM}/Alice' type='chat'><body>blocked</body></message>"),
|
||||
);
|
||||
assert!(denied.replies[0].contains("policy-violation"));
|
||||
assert!(denied.deliveries.is_empty());
|
||||
let own = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<message to='{ROOM}/Bob' type='chat'><body>self</body></message>"),
|
||||
);
|
||||
assert_eq!(own.deliveries.len(), 1);
|
||||
let config = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='{{xmpp:prosody.im}}muc#allow_modpm'><value>1</value></field></x></query></iq>"
|
||||
),
|
||||
);
|
||||
assert!(config.replies[0].contains("type='result'"));
|
||||
let allowed = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<message to='{ROOM}/Alice' type='chat'><body>allowed</body></message>"),
|
||||
);
|
||||
assert_eq!(allowed.deliveries.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn broadcast_roles_hide_visitors_and_configuration_announces_transition() {
|
||||
let (_root, mut m) = setup(json!({"moderated":true,"presence_broadcast":{"moderator":true}}));
|
||||
let join = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
assert!(join.deliveries.is_empty());
|
||||
assert!(
|
||||
join.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("/Bob") && p.contains("110"))
|
||||
);
|
||||
let update = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_presencebroadcast'><value>moderator</value><value>visitor</value></field></x></query></iq>"
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
update
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(to, p)| to == "alice@example.org/r"
|
||||
&& p.contains("/Bob")
|
||||
&& !p.contains("unavailable"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn language_config_sections_discovery_and_native_affiliation_hats() {
|
||||
let (root, mut m) = setup(json!({"persistent":true}));
|
||||
let config = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_lang'><value>de</value></field></x></query></iq>"
|
||||
),
|
||||
);
|
||||
assert!(config.replies[0].contains("type='result'"));
|
||||
let path = rust_xmpp::storage::path(root.path(), "rooms.example.org", Some("room"), "config");
|
||||
let mut frozen = rust_xmpp::storage::parse(&std::fs::read(&path).unwrap()).unwrap();
|
||||
let hats=rust_xmpp::storage::parse(br#"return {['alice@example.org']={hats={['urn:staff']={active=true,title='Staff'},['urn:off']={active=false,title='Hidden'}}}}"#).unwrap();
|
||||
frozen.set("_affiliation_data", hats);
|
||||
std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
let join = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><hats xmlns='urn:xmpp:hats:0'><hat uri='fake'/></hats></presence>"
|
||||
),
|
||||
);
|
||||
let presence = join.replies.iter().find(|p| p.contains("110")).unwrap();
|
||||
assert!(presence.contains("urn:staff"));
|
||||
assert!(!presence.contains("urn:off"));
|
||||
assert!(!presence.contains("fake"));
|
||||
let disco = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='get'><query xmlns='http://jabber.org/protocol/disco#info'/></iq>"
|
||||
),
|
||||
);
|
||||
assert!(disco.replies[0].contains("urn:xmpp:hats:0"));
|
||||
assert!(disco.replies[0].contains("<value>de</value>"));
|
||||
let form = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='get'><query xmlns='http://jabber.org/protocol/muc#owner'/></iq>"
|
||||
),
|
||||
);
|
||||
assert!(form.replies[0].contains("Room information"));
|
||||
assert!(form.replies[0].contains("muc#roomconfig_lang"));
|
||||
}
|
||||
#[test]
|
||||
fn messages_cannot_spoof_room_identity_hats_or_occupant_identifiers() {
|
||||
let (_root, mut m) = setup(json!({}));
|
||||
send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
for (kind, to) in [
|
||||
("groupchat", ROOM.to_string()),
|
||||
("chat", format!("{ROOM}/Bob")),
|
||||
] {
|
||||
let result = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<message type='{kind}' to='{to}'><body>safe</body><x xmlns='http://jabber.org/protocol/muc#user'><item jid='forged@example.org'/></x><hats xmlns='urn:xmpp:hats:0'><hat uri='forged-hat'/></hats><occupant-id xmlns='urn:xmpp:occupant-id:0' id='forged-id'/></message>"
|
||||
),
|
||||
);
|
||||
assert!(!result.deliveries.is_empty());
|
||||
for (_, p) in result.deliveries {
|
||||
assert!(!p.contains("forged"));
|
||||
assert!(p.contains("occupant-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn disabled_optional_vcard_and_occupant_ids_are_not_advertised_or_emitted() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_component_options(json!({"muc_vcard":false,"muc_occupant_id":false}), false);
|
||||
let join = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
);
|
||||
assert!(join.replies.iter().all(|p| !p.contains("occupant-id")));
|
||||
let disco = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='get'><query xmlns='http://jabber.org/protocol/disco#info'/></iq>"
|
||||
),
|
||||
);
|
||||
assert!(!disco.replies[0].contains("occupant-id"));
|
||||
assert!(!disco.replies[0].contains("vcard-temp"));
|
||||
let vcard = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<iq to='{ROOM}' type='get'><vCard xmlns='vcard-temp'/></iq>"),
|
||||
);
|
||||
assert!(vcard.replies[0].contains("feature-not-implemented"));
|
||||
}
|
||||
#[test]
|
||||
fn mam_all_rooms_presence_logging_public_queries_and_optional_totals() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
m.set_component_options(json!({"muc_log_all_rooms":true,"muc_log_by_default":false,"muc_log_presences":true,"muc_log_include_total":false}),true);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<message to='{ROOM}' type='groupchat'><body>retained</body></message>"),
|
||||
);
|
||||
let query = send(
|
||||
&mut m,
|
||||
"outsider@example.org/r",
|
||||
&format!("<iq to='{ROOM}' type='set'><query xmlns='urn:xmpp:mam:2'/></iq>"),
|
||||
);
|
||||
let text = query.replies.join("");
|
||||
assert!(text.contains("retained"));
|
||||
let mut saw_presence = false;
|
||||
rust_xmpp::archive::scan_store(
|
||||
root.path(),
|
||||
"rooms.example.org",
|
||||
Some("room"),
|
||||
"muc_log",
|
||||
|entry| {
|
||||
saw_presence |= entry.xml.contains("<presence");
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(saw_presence);
|
||||
assert!(!text.contains("<count>"));
|
||||
assert!(!text.contains(" index="));
|
||||
send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>"
|
||||
),
|
||||
);
|
||||
let mut count = 0;
|
||||
rust_xmpp::archive::scan_store(
|
||||
root.path(),
|
||||
"rooms.example.org",
|
||||
Some("room"),
|
||||
"muc_log",
|
||||
|_| {
|
||||
count += 1;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
#[test]
|
||||
fn offline_reserved_affiliates_follow_anonymity_and_roominfo_counts_nicknames() {
|
||||
let (root, _m) = setup(
|
||||
json!({"persistent":true,"whois":"moderators","presence_broadcast":{"none":true,"visitor":true,"participant":true,"moderator":true},"description":"Room description"}),
|
||||
);
|
||||
let path = rust_xmpp::storage::path(root.path(), "rooms.example.org", Some("room"), "config");
|
||||
let mut frozen = rust_xmpp::storage::parse(&std::fs::read(&path).unwrap()).unwrap();
|
||||
frozen.set("offline@example.org", "member".into());
|
||||
frozen.set("unnamed@example.org", "member".into());
|
||||
frozen.set(
|
||||
"_affiliation_data",
|
||||
rust_xmpp::storage::parse(
|
||||
b"return {['offline@example.org']={reserved_nickname='Offline'}}",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
std::fs::write(path, rust_xmpp::storage::serialize(&frozen).unwrap()).unwrap();
|
||||
let mut m = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
let guest = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
let offline = guest
|
||||
.replies
|
||||
.iter()
|
||||
.find(|p| p.contains("/Offline"))
|
||||
.unwrap();
|
||||
assert!(offline.contains("unavailable"));
|
||||
assert!(!offline.contains("offline@example.org"));
|
||||
assert!(
|
||||
guest
|
||||
.replies
|
||||
.iter()
|
||||
.all(|p| !p.contains("unnamed@example.org"))
|
||||
);
|
||||
let owner = send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Alice'/>"),
|
||||
);
|
||||
assert!(
|
||||
owner
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("unnamed@example.org"))
|
||||
);
|
||||
send(
|
||||
&mut m,
|
||||
"bob@example.org/second",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
let disco = send(
|
||||
&mut m,
|
||||
"bob@example.org/r",
|
||||
&format!(
|
||||
"<iq to='{ROOM}' type='get'><query xmlns='http://jabber.org/protocol/disco#info'/></iq>"
|
||||
),
|
||||
);
|
||||
assert!(disco.replies[0].contains("muc_semianonymous"));
|
||||
assert!(disco.replies[0].contains("<field var='muc#roominfo_occupants'><value>2</value>"));
|
||||
assert!(disco.replies[0].contains("Room description"));
|
||||
}
|
||||
#[test]
|
||||
fn recovery_claim_is_durable_and_unrelated_disconnect_keeps_temporary_claims() {
|
||||
let (root, mut first) = setup(json!({"persistent":false}));
|
||||
first.checkpoint_live().unwrap();
|
||||
drop(first);
|
||||
let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
recovered.prepare_recovery().unwrap();
|
||||
recovered.disconnect("unrelated@example.org/r");
|
||||
let restored = recovered
|
||||
.restore_for_session("alice@example.org/r")
|
||||
.unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("110") && p.contains("role='moderator'"))
|
||||
);
|
||||
drop(recovered);
|
||||
// No new checkpoint: an already consumed claim cannot resurrect stale role.
|
||||
let mut again = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
again.prepare_recovery().unwrap();
|
||||
let absent = again.restore_for_session("alice@example.org/r").unwrap();
|
||||
assert!(absent.replies.is_empty());
|
||||
assert!(absent.deliveries.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn large_optional_history_does_not_prevent_checkpointing_membership() {
|
||||
let (_root, mut m) = setup(json!({}));
|
||||
send(
|
||||
&mut m,
|
||||
"alice@example.org/r",
|
||||
&format!(
|
||||
"<message to='{ROOM}' type='groupchat'><body>{}</body></message>",
|
||||
"x".repeat(20 * 1024)
|
||||
),
|
||||
);
|
||||
m.checkpoint_live().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn explicit_new_nickname_consumes_old_proof_and_pending_nicks_are_reserved() {
|
||||
let (root, mut first) = setup(json!({"persistent":false}));
|
||||
send(
|
||||
&mut first,
|
||||
"bob@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
first.checkpoint_live().unwrap();
|
||||
drop(first);
|
||||
let mut recovered = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
recovered.prepare_recovery().unwrap();
|
||||
let stolen = send(
|
||||
&mut recovered,
|
||||
"mallory@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Bob'/>"),
|
||||
);
|
||||
assert!(stolen.replies.iter().any(|p| p.contains("conflict")));
|
||||
let explicit = send(
|
||||
&mut recovered,
|
||||
"alice@example.org/r",
|
||||
&format!("<presence to='{ROOM}/Changed'/>"),
|
||||
);
|
||||
assert!(explicit.replies.iter().any(|p| p.contains("110")));
|
||||
// Temporary room config and Bob's claim survive Alice's ordinary join.
|
||||
drop(recovered);
|
||||
let mut again = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
again.prepare_recovery().unwrap();
|
||||
assert!(
|
||||
again
|
||||
.restore_for_session("alice@example.org/r")
|
||||
.unwrap()
|
||||
.replies
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
again
|
||||
.restore_for_session("bob@example.org/r")
|
||||
.unwrap()
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("/Bob") && p.contains("110"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
|
||||
fn send(m: &mut Muc, j: &str, x: &str) -> ModuleResult {
|
||||
let d = roxmltree::Document::parse(x).unwrap();
|
||||
m.handle(j, d.root_element()).unwrap().unwrap()
|
||||
}
|
||||
fn join(m: &mut Muc, j: &str, n: &str) -> ModuleResult {
|
||||
send(
|
||||
m,
|
||||
j,
|
||||
&format!(
|
||||
"<presence to='r@rooms.test/{n}'><x xmlns='http://jabber.org/protocol/muc'/></presence>"
|
||||
),
|
||||
)
|
||||
}
|
||||
fn unlock(m: &mut Muc) {
|
||||
let r = send(
|
||||
m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' id='c' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("type='result'"));
|
||||
}
|
||||
#[test]
|
||||
fn join_lock_chat_leave() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
let a = join(&mut m, "alice@test/a", "Alice");
|
||||
assert!(a.replies[0].contains("201"));
|
||||
assert!(join(&mut m, "bob@test/b", "Bob").replies[0].contains("item-not-found"));
|
||||
unlock(&mut m);
|
||||
let b = join(&mut m, "bob@test/b", "Bob");
|
||||
assert!(b.replies.iter().any(|x| x.contains("Alice")));
|
||||
assert!(!b.replies[0].contains("alice@test"));
|
||||
let c = send(
|
||||
&mut m,
|
||||
"bob@test/b",
|
||||
"<message to='r@rooms.test' type='groupchat' id='x'><body>Hello</body></message>",
|
||||
);
|
||||
assert_eq!(c.deliveries.len(), 2);
|
||||
assert!(c.deliveries[0].1.contains("Hello"));
|
||||
assert_eq!(m.disconnect("bob@test/b").deliveries.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn authorization_and_conflict() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
assert!(join(&mut m, "bob@test/b", "Alice").replies[0].contains("conflict"));
|
||||
join(&mut m, "bob@test/b", "Bob");
|
||||
let r = send(
|
||||
&mut m,
|
||||
"bob@test/b",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item jid='bob@test' affiliation='owner'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("forbidden"));
|
||||
let r = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item jid='alice@test' affiliation='none'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("conflict"));
|
||||
let r = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#admin'><item jid='bob@test' affiliation='outcast'/></query></iq>",
|
||||
);
|
||||
assert_eq!(r.deliveries.len(), 2);
|
||||
assert!(join(&mut m, "bob@test/b", "Bob").replies[0].contains("forbidden"));
|
||||
}
|
||||
#[test]
|
||||
fn persistence_roundtrip() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
let r = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_persistentroom'><value>1</value></field><field var='muc#roomconfig_roomname'><value>Комната</value></field></x></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("result"));
|
||||
drop(m);
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
let r = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='get' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("Комната"));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
}
|
||||
#[test]
|
||||
fn archive_history_and_permissions() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_defaults(serde_json::json!({"archiving":true,"persistent":true}));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<message to='r@rooms.test' type='groupchat'><body>Archived</body></message>",
|
||||
);
|
||||
let query = "<iq type='set' to='r@rooms.test' id='mam'><query xmlns='urn:xmpp:mam:2'/></iq>";
|
||||
// Native public/open room archives are readable by unaffiliated users.
|
||||
assert!(send(&mut m, "outsider@test/a", query).replies[0].contains("Archived"));
|
||||
let r = send(&mut m, "alice@test/a", query);
|
||||
assert!(r.replies[0].contains("Archived"));
|
||||
assert!(r.replies.last().unwrap().contains("<fin"));
|
||||
let joined = join(&mut m, "bob@test/b", "Bob");
|
||||
assert!(
|
||||
joined
|
||||
.replies
|
||||
.iter()
|
||||
.any(|x| x.contains("Archived") && x.contains("urn:xmpp:delay"))
|
||||
);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_membersonly'><value>1</value></field></x></query></iq>",
|
||||
);
|
||||
assert!(send(&mut m, "outsider@test/a", query).replies[0].contains("forbidden"));
|
||||
}
|
||||
#[test]
|
||||
fn nick_invite_destroy_and_local_creation() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_local_domain("test".into());
|
||||
assert!(join(&mut m, "external@else/a", "External").replies[0].contains("forbidden"));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let renamed = join(&mut m, "alice@test/a", "NewName");
|
||||
assert!(renamed.replies[0].contains("303"));
|
||||
assert!(renamed.replies[0].contains("nick='NewName'"));
|
||||
let inv = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<message to='r@rooms.test'><x xmlns='http://jabber.org/protocol/muc#user'><invite to='bob@test'><reason>Join us</reason></invite></x></message>",
|
||||
);
|
||||
assert_eq!(inv.deliveries[0].0, "bob@test");
|
||||
assert!(inv.deliveries[0].1.contains("Join us"));
|
||||
let destroy = "<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>";
|
||||
assert!(send(&mut m, "bob@test/a", destroy).replies[0].contains("forbidden"));
|
||||
assert_eq!(send(&mut m, "alice@test/a", destroy).deliveries.len(), 1);
|
||||
assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("201"));
|
||||
}
|
||||
#[test]
|
||||
fn preserves_unknown_binary_mixed_room_fields() {
|
||||
use rust_xmpp::storage::{self, LuaValue as V};
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut raw = V::table();
|
||||
raw.set("_jid", "r@rooms.test".into());
|
||||
raw.set("alice@test", "owner".into());
|
||||
let mut data = V::table();
|
||||
data.set("persistent", V::Bool(true));
|
||||
data.set("salt", V::String(vec![0xff, 0x00, 0x80]));
|
||||
let mut mixed = V::table();
|
||||
mixed.set("extension", "original".into());
|
||||
mixed.set_key(V::Integer(1), "first".into());
|
||||
data.set("mixed", mixed.clone());
|
||||
raw.set("_data", data);
|
||||
storage::save(t.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_roomname'><value>New</value></field></x></query></iq>",
|
||||
);
|
||||
let saved = storage::load(t.path(), "rooms.test", Some("r"), "config")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let d = saved.get("_data").unwrap();
|
||||
assert_eq!(d.get("salt"), Some(&V::String(vec![0xff, 0x00, 0x80])));
|
||||
assert_eq!(d.get("mixed"), Some(&mixed));
|
||||
assert_eq!(d.get("name").unwrap().as_str(), Some("New"));
|
||||
}
|
||||
#[test]
|
||||
fn multiple_resources_share_nick_and_private_delivery() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
join(&mut m, "bob@test/a", "Bob");
|
||||
let second = join(&mut m, "bob@test/b", "Bob");
|
||||
assert!(!second.replies.iter().any(|s| s.contains("type='error'")));
|
||||
let pm = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<message to='r@rooms.test/Bob' type='chat'><body>Private</body></message>",
|
||||
);
|
||||
assert_eq!(pm.deliveries.len(), 2);
|
||||
assert_eq!(m.disconnect("bob@test/a").deliveries.len(), 0);
|
||||
assert_eq!(m.disconnect("bob@test/b").deliveries.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn iq_response_requires_expected_sender() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
join(&mut m, "bob@test/b", "Bob");
|
||||
let request = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq to='r@rooms.test/Bob' type='get' id='original'><ping xmlns='urn:xmpp:ping'/></iq>",
|
||||
);
|
||||
let packet = roxmltree::Document::parse(&request.deliveries[0].1).unwrap();
|
||||
let id = packet.root_element().attribute("id").unwrap();
|
||||
let answer = format!("<iq type='result' to='r@rooms.test/Alice' id='{id}'/>");
|
||||
assert!(
|
||||
send(&mut m, "mallory@test/c", &answer)
|
||||
.deliveries
|
||||
.is_empty()
|
||||
);
|
||||
let response = send(&mut m, "bob@test/b", &answer);
|
||||
assert_eq!(response.deliveries[0].0, "alice@test/a");
|
||||
assert!(response.deliveries[0].1.contains("id='original'"));
|
||||
}
|
||||
#[test]
|
||||
fn persistent_destroy_creates_prosody_tombstone() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_defaults(serde_json::json!({"persistent":true}));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy><reason>Closed</reason></destroy></query></iq>",
|
||||
);
|
||||
drop(m);
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("gone"));
|
||||
let saved = rust_xmpp::storage::load(t.path(), "rooms.test", Some("r"), "config")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
saved.get("_data").unwrap().get("destroyed"),
|
||||
Some(&rust_xmpp::storage::LuaValue::Bool(true))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn reserved_nickname_blocks_other_users() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_defaults(serde_json::json!({"persistent":true}));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let reg = "<iq type='set' to='r@rooms.test'><query xmlns='jabber:iq:register'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#register</value></field><field var='muc#register_roomnick'><value>Reserved</value></field></x></query></iq>";
|
||||
assert!(send(&mut m, "alice@test/a", reg).replies[0].contains("type='result'"));
|
||||
assert!(join(&mut m, "bob@test/a", "Reserved").replies[0].contains("conflict"));
|
||||
drop(m);
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
assert!(join(&mut m, "bob@test/a", "Reserved").replies[0].contains("conflict"));
|
||||
assert!(!join(&mut m, "alice@test/a", "Reserved").replies[0].contains("type='error'"));
|
||||
}
|
||||
#[test]
|
||||
fn occupant_id_is_stable_and_spoofed_ids_removed() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let first = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<message to='r@rooms.test' type='groupchat'><body>Hi</body><occupant-id xmlns='urn:xmpp:occupant-id:0' id='fake'/></message>",
|
||||
);
|
||||
let d = roxmltree::Document::parse(&first.deliveries[0].1).unwrap();
|
||||
let ids = d
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name(("urn:xmpp:occupant-id:0", "occupant-id")))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids.len(), 1);
|
||||
let id = ids[0].attribute("id").unwrap().to_owned();
|
||||
assert_ne!(id, "fake");
|
||||
let next = join(&mut m, "alice@test/b", "Alice");
|
||||
assert!(next.replies.iter().any(|p| p.contains(&id)));
|
||||
}
|
||||
#[test]
|
||||
fn switching_to_members_only_removes_unaffiliated_users() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
join(&mut m, "bob@test/a", "Bob");
|
||||
let r = send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq type='set' to='r@rooms.test'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'><field var='muc#roomconfig_membersonly'><value>1</value></field></x></query></iq>",
|
||||
);
|
||||
assert!(
|
||||
r.deliveries
|
||||
.iter()
|
||||
.any(|(j, p)| j == "bob@test/a" && p.contains("322"))
|
||||
);
|
||||
assert!(
|
||||
send(
|
||||
&mut m,
|
||||
"bob@test/a",
|
||||
"<message type='groupchat' to='r@rooms.test'><body>Still here?</body></message>"
|
||||
)
|
||||
.replies[0]
|
||||
.contains("not-acceptable")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn room_vcard_owner_only_and_avatar_presence() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let set = "<iq type='set' to='r@rooms.test'><vCard xmlns='vcard-temp'><FN>Test Room</FN><PHOTO><TYPE>image/png</TYPE><BINVAL>aGVsbG8=</BINVAL></PHOTO></vCard></iq>";
|
||||
assert!(send(&mut m, "bob@test/a", set).replies[0].contains("forbidden"));
|
||||
let result = send(&mut m, "alice@test/a", set);
|
||||
assert!(result.replies[0].contains("result"));
|
||||
assert!(
|
||||
result
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(_, p)| p.contains("vcard-temp:x:update"))
|
||||
);
|
||||
let get = send(
|
||||
&mut m,
|
||||
"bob@test/a",
|
||||
"<iq type='get' to='r@rooms.test'><vCard xmlns='vcard-temp'/></iq>",
|
||||
);
|
||||
assert!(get.replies[0].contains("Test Room"));
|
||||
let joined = join(&mut m, "bob@test/a", "Bob");
|
||||
assert!(
|
||||
joined
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn visitor_voice_request_cannot_spoof_identity_or_grant_itself_voice() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_defaults(serde_json::json!({"moderated":true}));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
join(&mut m, "bob@test/a", "Bob");
|
||||
let message = "<message to='r@rooms.test' type='groupchat'><body>Hello</body></message>";
|
||||
assert!(send(&mut m, "bob@test/a", message).replies[0].contains("forbidden"));
|
||||
let request = "<message to='r@rooms.test' type='normal'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#request</value></field><field var='muc#jid'><value>mallory@test/a</value></field><field var='muc#request_allow'><value>1</value></field><field var='muc#role'><value>moderator</value></field></x></message>";
|
||||
let forwarded = send(&mut m, "bob@test/a", request);
|
||||
assert_eq!(forwarded.deliveries.len(), 1);
|
||||
assert_eq!(forwarded.deliveries[0].0, "alice@test/a");
|
||||
assert!(forwarded.deliveries[0].1.contains("bob@test/a"));
|
||||
assert!(!forwarded.deliveries[0].1.contains("mallory"));
|
||||
assert!(send(&mut m, "bob@test/a", message).replies[0].contains("forbidden"));
|
||||
let grant = request.replace("mallory@test/a", "bob@test/a");
|
||||
let result = send(&mut m, "alice@test/a", &grant);
|
||||
assert_eq!(result.deliveries.len(), 2);
|
||||
assert!(result.deliveries[0].1.contains("role='participant'"));
|
||||
assert_eq!(send(&mut m, "bob@test/a", message).deliveries.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn maintenance_expires_idle_archives_and_tombstones() {
|
||||
use rust_xmpp::archive::{self, Entry};
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_defaults(serde_json::json!({"persistent":true,"archiving":true}));
|
||||
m.set_archive_retention(Some(60.0));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64();
|
||||
for (id, when) in [("old", now - 100.0), ("new", now - 10.0)] {
|
||||
archive::append_new(
|
||||
t.path(),
|
||||
"rooms.test",
|
||||
Some("r"),
|
||||
"muc_log",
|
||||
Entry {
|
||||
id: id.into(),
|
||||
when,
|
||||
peer: "alice@test/a".into(),
|
||||
xml:
|
||||
"<message xmlns='jabber:client' type='groupchat'><body>Stored</body></message>"
|
||||
.into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(m);
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_archive_retention(Some(60.0));
|
||||
m.maintenance(now).unwrap();
|
||||
let entries = archive::load_store(t.path(), "rooms.test", Some("r"), "muc_log").unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].id, "new");
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<iq to='r@rooms.test' type='set'><query xmlns='http://jabber.org/protocol/muc#owner'><destroy/></query></iq>",
|
||||
);
|
||||
m.maintenance(now + 32.0 * 86400.0).unwrap();
|
||||
assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("201"));
|
||||
}
|
||||
#[test]
|
||||
fn restored_temporary_room_is_deleted_after_last_disconnect() {
|
||||
use rust_xmpp::storage::{self, LuaValue as V};
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let raw = storage::parse(
|
||||
b"return {_jid='r@rooms.test';_data={persistent=false};['alice@test']='owner'}",
|
||||
)
|
||||
.unwrap();
|
||||
storage::save(t.path(), "rooms.test", Some("r"), "config", &raw).unwrap();
|
||||
storage::save(t.path(), "rooms.test", Some("r"), "state", &V::table()).unwrap();
|
||||
let mut index = V::table();
|
||||
index.set("r@rooms.test", V::Bool(true));
|
||||
storage::save(t.path(), "rooms.test", None, "persistent", &index).unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
m.disconnect("alice@test/a");
|
||||
assert!(
|
||||
storage::load(t.path(), "rooms.test", Some("r"), "config")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
storage::load(t.path(), "rooms.test", Some("r"), "state")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(join(&mut m, "bob@test/a", "Bob").replies[0].contains("201"));
|
||||
}
|
||||
#[test]
|
||||
fn configured_lock_timeout_expires_without_owner_submission() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_component_options(serde_json::json!({"muc_room_lock_timeout":0}), false);
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
assert!(!join(&mut m, "bob@test/a", "Bob").replies[0].contains("item-not-found"));
|
||||
}
|
||||
#[test]
|
||||
fn native_muc_archive_type_and_anonymity_are_preserved() {
|
||||
use rust_xmpp::archive::{self, Entry};
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(t.path().into(), "rooms.test".into());
|
||||
m.set_defaults(serde_json::json!({"persistent":true,"archiving":true,"whois":"moderators"}));
|
||||
join(&mut m, "alice@test/a", "Alice");
|
||||
unlock(&mut m);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64();
|
||||
archive::append_new(t.path(),"rooms.test",Some("r"),"muc_log",Entry{id:"native".into(),when:now,peer:"message<groupchat".into(),xml:"<message xmlns='jabber:client' type='groupchat' from='r@rooms.test/OldNick' to='secret@example/r'><body>Native old message</body><x xmlns='http://jabber.org/protocol/muc#user'><item jid='secret@example'/></x></message>".into()}).unwrap();
|
||||
let joined = join(&mut m, "bob@test/b", "Bob");
|
||||
let historic = joined
|
||||
.replies
|
||||
.iter()
|
||||
.find(|s| s.contains("Native old message"))
|
||||
.unwrap();
|
||||
assert!(!historic.contains("secret@example"));
|
||||
let query = "<iq to='r@rooms.test' type='set'><query xmlns='urn:xmpp:mam:2'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>urn:xmpp:mam:2</value></field><field var='with'><value>secret@example/r</value></field></x></query></iq>";
|
||||
let result = send(&mut m, "bob@test/b", query);
|
||||
assert!(
|
||||
result
|
||||
.replies
|
||||
.iter()
|
||||
.any(|s| s.contains("Native old message"))
|
||||
);
|
||||
assert!(!result.replies.iter().any(|s| s.contains("secret@example")));
|
||||
send(
|
||||
&mut m,
|
||||
"alice@test/a",
|
||||
"<message to='r@rooms.test' type='groupchat'><body>New</body></message>",
|
||||
);
|
||||
assert!(
|
||||
archive::load_store(t.path(), "rooms.test", Some("r"), "muc_log")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|e| e.peer == "message<groupchat")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use rust_xmpp::muc_history::{HistoryRequest, RoomHistory};
|
||||
|
||||
fn packet(id: usize, content: &str) -> String {
|
||||
format!(
|
||||
"<message xmlns='jabber:client' type='groupchat' from='room@rooms.example/Alice' id='{id}'>{content}</message>"
|
||||
)
|
||||
}
|
||||
fn join(attributes: &str) -> String {
|
||||
format!(
|
||||
"<presence><x xmlns='http://jabber.org/protocol/muc'><history {attributes}/></x></presence>"
|
||||
)
|
||||
}
|
||||
fn populated() -> RoomHistory {
|
||||
let mut history = RoomHistory::new(3);
|
||||
for id in 1..=4 {
|
||||
history.push(
|
||||
&packet(id, &format!("<body>message {id}</body>")),
|
||||
id as i64 * 10,
|
||||
);
|
||||
}
|
||||
history
|
||||
}
|
||||
fn ids(packets: &[String]) -> Vec<String> {
|
||||
packets
|
||||
.iter()
|
||||
.map(|xml| {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
doc.root_element().attribute("id").unwrap().to_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_history_is_bounded_and_returns_oldest_first() {
|
||||
let history = populated();
|
||||
assert_eq!(history.len(), 3);
|
||||
assert_eq!(
|
||||
ids(&history.replay("room@rooms.example", "bob@example/r", "<presence/>", 40)),
|
||||
["2", "3", "4"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn explicit_count_and_zero_limit() {
|
||||
let history = populated();
|
||||
assert_eq!(
|
||||
ids(&history.replay("room", "bob@example/r", &join("maxstanzas='2'"), 40)),
|
||||
["3", "4"]
|
||||
);
|
||||
for attrs in ["maxstanzas='0'", "maxchars='0'", "maxstanzas='-1'"] {
|
||||
assert!(
|
||||
history
|
||||
.replay("room", "bob@example/r", &join(attrs), 40)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn default_count_applies_only_without_valid_explicit_constraints() {
|
||||
let mut history = populated();
|
||||
history.set_default_messages(1);
|
||||
assert_eq!(
|
||||
ids(&history.replay("room", "bob/r", "<presence/>", 40)),
|
||||
["4"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&history.replay("room", "bob/r", &join("seconds='30'"), 40)),
|
||||
["2", "3", "4"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&history.replay("room", "bob/r", &join("maxstanzas='bad'"), 40)),
|
||||
["4"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn since_and_seconds_use_stricter_cutoff_inclusive() {
|
||||
let history = populated();
|
||||
let attrs = "since='1970-01-01T00:00:20Z' seconds='10'";
|
||||
assert_eq!(
|
||||
ids(&history.replay("room", "bob/r", &join(attrs), 40)),
|
||||
["3", "4"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&history.replay(
|
||||
"room",
|
||||
"bob/r",
|
||||
&join("since='1970-01-01T00:00:30.001Z'"),
|
||||
40
|
||||
)),
|
||||
["4"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn replay_carries_room_delay_and_recipient() {
|
||||
let history = populated();
|
||||
let replay = history.replay("room", "bob@example/r&device", &join("maxstanzas='1'"), 40);
|
||||
let doc = roxmltree::Document::parse(&replay[0]).unwrap();
|
||||
let message = doc.root_element();
|
||||
assert_eq!(message.attribute("to"), Some("bob@example/r&device"));
|
||||
assert_eq!(message.attribute("from"), Some("room@rooms.example/Alice"));
|
||||
let delays: Vec<_> = message
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name(("urn:xmpp:delay", "delay")))
|
||||
.collect();
|
||||
assert_eq!(delays.len(), 1);
|
||||
assert_eq!(delays[0].attribute("from"), Some("room@rooms.example"));
|
||||
assert_eq!(delays[0].attribute("stamp"), Some("1970-01-01T00:00:40Z"));
|
||||
}
|
||||
#[test]
|
||||
fn maxchars_counts_transmitted_utf8_bytes_including_address_and_delay() {
|
||||
let mut history = RoomHistory::new(3);
|
||||
history.push(&packet(1, "<body>é🦀</body>"), 10);
|
||||
let all = history.replay("room", "bob/r", "<presence/>", 10);
|
||||
let size = all[0].len();
|
||||
assert_eq!(
|
||||
history
|
||||
.replay("room", "bob/r", &join(&format!("maxchars='{size}'")), 10)
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
history
|
||||
.replay(
|
||||
"room",
|
||||
"bob/r",
|
||||
&join(&format!("maxchars='{}'", size - 1)),
|
||||
10
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
history
|
||||
.replay(
|
||||
"room",
|
||||
"a-much-longer-recipient/r",
|
||||
&join(&format!("maxchars='{size}'")),
|
||||
10
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn subjects_and_hints_follow_native_history_eligibility() {
|
||||
let mut history = RoomHistory::new(20);
|
||||
for content in [
|
||||
"<subject>topic</subject>",
|
||||
"<body>x</body><no-store xmlns='urn:xmpp:hints'/>",
|
||||
"<body>x</body><no-permanent-store xmlns='urn:xmpp:hints'/>",
|
||||
"<active xmlns='http://jabber.org/protocol/chatstates'/>",
|
||||
] {
|
||||
history.push(&packet(0, content), 1);
|
||||
}
|
||||
assert!(history.is_empty());
|
||||
for content in [
|
||||
"<body/>",
|
||||
"<store xmlns='urn:xmpp:hints'/>",
|
||||
"<encryption xmlns='urn:xmpp:eme:0'/>",
|
||||
"<displayed xmlns='urn:xmpp:chat-markers:0' id='1'/>",
|
||||
] {
|
||||
history.push(&packet(1, content), 1);
|
||||
}
|
||||
assert_eq!(history.len(), 4);
|
||||
}
|
||||
#[test]
|
||||
fn capacity_changes_trim_and_zero_disables_history() {
|
||||
let mut history = populated();
|
||||
history.set_capacity(1);
|
||||
assert_eq!(
|
||||
ids(&history.replay("room", "bob/r", "<presence/>", 40)),
|
||||
["4"]
|
||||
);
|
||||
history.set_capacity(0);
|
||||
history.push(&packet(5, "<body>ignored</body>"), 50);
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn snapshot_restores_last_message_without_adding_another_delay() {
|
||||
let history = populated();
|
||||
let (stanza, timestamp) = history.last_message().unwrap();
|
||||
let mut restored = RoomHistory::new(20);
|
||||
restored.restore_last_message(stanza, timestamp);
|
||||
let replay = restored.replay("room", "bob/r", "<presence/>", 99);
|
||||
assert_eq!(ids(&replay), ["4"]);
|
||||
let doc = roxmltree::Document::parse(&replay[0]).unwrap();
|
||||
assert_eq!(
|
||||
doc.root_element()
|
||||
.children()
|
||||
.filter(|n| n.has_tag_name(("urn:xmpp:delay", "delay")))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_direct_history_children_are_parsed() {
|
||||
let request = HistoryRequest::parse(
|
||||
"<presence><wrapper><x xmlns='http://jabber.org/protocol/muc'><history maxstanzas='0'/></x></wrapper></presence>",
|
||||
40,
|
||||
);
|
||||
assert!(!request.has_constraints());
|
||||
let request = HistoryRequest::parse(&join("seconds='NaN' maxchars='inf' maxstanzas='2.9'"), 40);
|
||||
assert_eq!(request.maxstanzas, Some(2));
|
||||
assert_eq!(request.maxchars, None);
|
||||
assert_eq!(request.since, None);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Exact upstream MUC + muc_mam oracle, with no replacement storage/module API.
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult};
|
||||
fn send(muc: &mut Muc, xml: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
muc.handle("alice@example.org/r", doc.root_element())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn actual_prosody_muc_mam_reads_rust_and_writes_native_archive() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut muc = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
muc.set_defaults(serde_json::json!({"persistent":true,"archiving":true,"whois":"moderators"}));
|
||||
send(
|
||||
&mut muc,
|
||||
"<presence to='room@rooms.example.org/Alice'><x xmlns='http://jabber.org/protocol/muc'/></presence>",
|
||||
);
|
||||
send(
|
||||
&mut muc,
|
||||
"<iq type='set' to='room@rooms.example.org'><query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'/></query></iq>",
|
||||
);
|
||||
send(
|
||||
&mut muc,
|
||||
"<message type='groupchat' to='room@rooms.example.org' id='rust-message'><body>Rust archived body</body></message>",
|
||||
);
|
||||
drop(muc);
|
||||
let bootstrap = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/prosody_compat/oracle.lua"
|
||||
))
|
||||
.unwrap();
|
||||
let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap();
|
||||
let script = format!(
|
||||
"{bootstrap}\n{}",
|
||||
r#"
|
||||
config.set('rooms.example.org','component_module','muc');
|
||||
config.set('rooms.example.org','modules_enabled',{'muc_mam'});
|
||||
config.set('rooms.example.org','muc_log_expires_after','never');
|
||||
assert(require('prosody.core.hostmanager').activate('rooms.example.org'));
|
||||
if not mm.get_module('rooms.example.org','muc_mam') then assert(mm.load('rooms.example.org','muc_mam')); end
|
||||
local muc=assert(mm.get_module('rooms.example.org','muc'));
|
||||
local room=assert(muc.get_room_from_jid('room@rooms.example.org'));
|
||||
assert(room:get_affiliation('alice@example.org')=='owner');
|
||||
assert(room:get_persistent());assert(room._data.archiving);
|
||||
local archive=assert(sm.open('rooms.example.org','muc_log','archive'));
|
||||
local count=0;
|
||||
for id,item,when,peer in assert(archive:find('room',{with='message<groupchat'})) do
|
||||
count=count+1;assert(peer=='message<groupchat');assert(item:get_child_text('body')=='Rust archived body');
|
||||
end
|
||||
assert(count==1,'Rust records not visible through native MUC archive query');
|
||||
room:broadcast_message(st.message({xmlns='jabber:client',type='groupchat',from='room@rooms.example.org/Alice',id='native-message'},'Native archived body'));
|
||||
local found=false;
|
||||
for id,item,when,peer in assert(archive:find('room',{with='message<groupchat'})) do
|
||||
assert(peer=='message<groupchat');if item:get_child_text('body')=='Native archived body' then found=true;end
|
||||
end
|
||||
assert(found,'actual mod_muc_mam did not archive native broadcast');
|
||||
room:set_name('Native room name');assert(room:save(true));
|
||||
print('NATIVE_MUC_MAM_ROUNDTRIP_OK');
|
||||
"#
|
||||
);
|
||||
let script_path = root.path().join("native-muc.lua");
|
||||
std::fs::write(&script_path, script).unwrap();
|
||||
let output = std::process::Command::new(
|
||||
std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()),
|
||||
)
|
||||
.arg(&script_path)
|
||||
.arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap())
|
||||
.arg(root.path())
|
||||
.arg("muc")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let mut muc = Muc::new(root.path().into(), "rooms.example.org".into());
|
||||
let result = send(
|
||||
&mut muc,
|
||||
"<iq type='set' to='room@rooms.example.org'><query xmlns='urn:xmpp:mam:2'/></iq>",
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.replies
|
||||
.iter()
|
||||
.any(|s| s.contains("Native archived body")),
|
||||
"{:?}",
|
||||
result.replies
|
||||
);
|
||||
let disco = send(
|
||||
&mut muc,
|
||||
"<iq type='get' to='room@rooms.example.org'><query xmlns='http://jabber.org/protocol/disco#info'/></iq>",
|
||||
);
|
||||
assert!(disco.replies[0].contains("Native room name"));
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use rust_xmpp::muc_recovery::{self as recovery, OccupantSnapshot, PendingOccupants};
|
||||
|
||||
fn occupant() -> OccupantSnapshot {
|
||||
OccupantSnapshot { jid: "alice@test/phone".into(), nick: "Alice".into(), role: "participant".into(), presence_payload: "<show>away</show><status>hi & bye</status><x xmlns='http://jabber.org/protocol/muc'><password>secret</password></x>".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_checkpoint_roundtrip_filters_sensitive_extensions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
recovery::save_room_checkpoint(
|
||||
dir.path(),
|
||||
"rooms.test",
|
||||
"r@rooms.test",
|
||||
&[occupant()],
|
||||
Some(&"a".repeat(64)),
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
let occupants = recovery::load_room(dir.path(), "rooms.test", "r@rooms.test").unwrap();
|
||||
assert_eq!(occupants.len(), 1);
|
||||
assert_eq!(occupants[0].jid, "alice@test/phone");
|
||||
let wrapped = format!("<presence>{}</presence>", occupants[0].presence_payload);
|
||||
let parsed = roxmltree::Document::parse(&wrapped).unwrap();
|
||||
let fields = parsed
|
||||
.root_element()
|
||||
.children()
|
||||
.filter(|node| node.is_element())
|
||||
.map(|node| (node.tag_name().name(), node.text().unwrap_or("")))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(fields.len(), 2);
|
||||
assert_eq!(fields.get("show"), Some(&"away"));
|
||||
assert_eq!(fields.get("status"), Some(&"hi & bye"));
|
||||
assert!(
|
||||
parsed
|
||||
.descendants()
|
||||
.filter(|node| node.is_element())
|
||||
.all(|node| matches!(node.tag_name().name(), "presence" | "show" | "status"))
|
||||
);
|
||||
assert_eq!(
|
||||
recovery::load_checkpoint_time(dir.path(), "rooms.test", "r@rooms.test").unwrap(),
|
||||
Some(100)
|
||||
);
|
||||
let raw = std::fs::read_to_string(rust_xmpp::storage::path(
|
||||
dir.path(),
|
||||
"rooms.test",
|
||||
Some("r"),
|
||||
"state",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!raw.contains("secret"));
|
||||
assert!(!raw.contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_claim_requires_exact_authenticated_resource_and_expires() {
|
||||
let mut pending = PendingOccupants::default();
|
||||
pending
|
||||
.insert_checkpoint("r@rooms.test".into(), vec![occupant()], 100, 200)
|
||||
.unwrap();
|
||||
assert!(pending.candidates("alice@test/other", 200).is_empty());
|
||||
assert!(
|
||||
pending
|
||||
.claim("r@rooms.test", "mallory@test/phone", "Alice", 200)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(pending.candidates("alice@test/phone", 200).len(), 1);
|
||||
assert_eq!(pending.expire(400), 1);
|
||||
assert!(
|
||||
pending
|
||||
.claim("r@rooms.test", "alice@test/phone", "Alice", 400)
|
||||
.is_none()
|
||||
);
|
||||
pending
|
||||
.insert_checkpoint("r@rooms.test".into(), vec![occupant()], 100, 401)
|
||||
.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_is_one_time_and_does_not_change_untrusted_role() {
|
||||
let mut pending = PendingOccupants::default();
|
||||
pending
|
||||
.insert("r@rooms.test".into(), vec![occupant()], 100)
|
||||
.unwrap();
|
||||
assert!(
|
||||
pending
|
||||
.claim("r@rooms.test", "alice@test/phone", "Wrong", 101)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pending
|
||||
.claim("r@rooms.test", "alice@test/phone", "Alice", 101)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
pending
|
||||
.claim("r@rooms.test", "alice@test/phone", "Alice", 101)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_last_message_roundtrip_preserves_namespaces_and_rejects_other_rooms() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let packet = "<message xmlns='jabber:client' type='groupchat' from='r@rooms.test/Alice'><body>hello</body><delay xmlns='urn:xmpp:delay' stamp='2026-01-01T00:00:00Z'/></message>";
|
||||
recovery::set_last_message(
|
||||
dir.path(),
|
||||
"rooms.test",
|
||||
"r@rooms.test",
|
||||
Some((packet, 100)),
|
||||
)
|
||||
.unwrap();
|
||||
let (saved, at) = recovery::load_last_message(dir.path(), "rooms.test", "r@rooms.test")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(at, 100);
|
||||
let doc = roxmltree::Document::parse(&saved).unwrap();
|
||||
assert_eq!(
|
||||
doc.root_element().tag_name().namespace(),
|
||||
Some("jabber:client")
|
||||
);
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.is_element() && n.tag_name().namespace() == Some("urn:xmpp:delay"))
|
||||
);
|
||||
assert!(
|
||||
recovery::set_last_message(
|
||||
dir.path(),
|
||||
"rooms.test",
|
||||
"other@rooms.test",
|
||||
Some((packet, 100))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
recovery::set_last_message(dir.path(), "rooms.test", "r@rooms.test", None).unwrap();
|
||||
assert!(
|
||||
recovery::load_last_message(dir.path(), "rooms.test", "r@rooms.test")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_snapshot_keeps_original_checkpoint_time() {
|
||||
let mut pending = PendingOccupants::default();
|
||||
pending
|
||||
.insert_checkpoint("r@rooms.test".into(), vec![occupant()], 100, 200)
|
||||
.unwrap();
|
||||
let (saved_at, occupants) = pending.room_snapshot("r@rooms.test", 300).unwrap();
|
||||
assert_eq!(saved_at, 100);
|
||||
let mut restarted = PendingOccupants::default();
|
||||
restarted
|
||||
.insert_checkpoint("r@rooms.test".into(), occupants, saved_at, 350)
|
||||
.unwrap();
|
||||
assert!(restarted.room_snapshot("r@rooms.test", 400).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_status_never_prevents_membership_checkpoint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for length in [4096, 256 * 1024] {
|
||||
let mut original = occupant();
|
||||
original.presence_payload = format!("<status>{}</status>", "x".repeat(length));
|
||||
recovery::save_room_checkpoint(
|
||||
dir.path(),
|
||||
"rooms.test",
|
||||
"r@rooms.test",
|
||||
&[original.clone()],
|
||||
None,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
let restored = recovery::load_room(dir.path(), "rooms.test", "r@rooms.test").unwrap();
|
||||
assert_eq!(restored.len(), 1);
|
||||
assert_eq!(restored[0].jid, original.jid);
|
||||
assert_eq!(restored[0].nick, original.nick);
|
||||
assert_eq!(restored[0].role, original.role);
|
||||
assert!(restored[0].presence_payload.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_fingerprint_is_independent_of_lua_table_iteration_order() {
|
||||
use rust_xmpp::storage::{self, LuaValue as V};
|
||||
let a = storage::parse(
|
||||
b"return {a=1,_data={password='hidden',nested={x=true,y='value'}},owner='owner'}",
|
||||
)
|
||||
.unwrap();
|
||||
let b = storage::parse(
|
||||
b"return {owner='owner',_data={nested={y='value',x=true},password='hidden'},a=1}",
|
||||
)
|
||||
.unwrap();
|
||||
let expected = rust_xmpp::muc_recovery::configuration_fingerprint(&a).unwrap();
|
||||
assert_eq!(
|
||||
expected,
|
||||
rust_xmpp::muc_recovery::configuration_fingerprint(&b).unwrap()
|
||||
);
|
||||
for _ in 0..32 {
|
||||
let value = storage::parse(storage::serialize(&a).unwrap().as_bytes()).unwrap();
|
||||
assert_eq!(
|
||||
expected,
|
||||
rust_xmpp::muc_recovery::configuration_fingerprint(&value).unwrap()
|
||||
);
|
||||
}
|
||||
let mut changed = b;
|
||||
changed.set("owner", V::from("outcast"));
|
||||
assert_ne!(
|
||||
expected,
|
||||
rust_xmpp::muc_recovery::configuration_fingerprint(&changed).unwrap()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live MUC membership recovery across SIGTERM over real loopback STARTTLS.
|
||||
|
||||
Usage: python3 tests/muc_restart_network.py target/release/rust-xmpp
|
||||
The sockets stay open until shutdown, so ordinary disconnect cleanup cannot
|
||||
substitute for checkpointing the room's live occupants.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from _wire_client import Client, local
|
||||
|
||||
MUC = 'http://jabber.org/protocol/muc'
|
||||
USER = MUC + '#user'
|
||||
ROOMS = ('persistent@rooms.localhost', 'temporary@rooms.localhost')
|
||||
|
||||
|
||||
def iq(client, identifier, payload, room, error=False):
|
||||
client.send(f"<iq type='set' id='{identifier}' to='{room}'>{payload}</iq>")
|
||||
return client.iq(identifier, error=error)
|
||||
|
||||
|
||||
def presence(client, room, nick):
|
||||
return client.take(lambda e: local(e) == 'presence' and e.get('from') == f'{room}/{nick}')
|
||||
|
||||
|
||||
def occupant(packet, role, affiliation=None):
|
||||
assert packet.get('type') is None, ET.tostring(packet)
|
||||
item = packet.find(f'{{{USER}}}x/{{{USER}}}item')
|
||||
assert item is not None and item.get('role') == role, ET.tostring(packet)
|
||||
if affiliation is not None:
|
||||
assert item.get('affiliation') == affiliation, ET.tostring(packet)
|
||||
|
||||
|
||||
def join(client, room, nick):
|
||||
client.send(f"<presence to='{room}/{nick}'><x xmlns='{MUC}'><history maxstanzas='0'/></x><show>away</show><status>before restart</status></presence>")
|
||||
return presence(client, room, nick)
|
||||
|
||||
|
||||
def not_joined(client, room):
|
||||
# A ping fences the initial broadcast presence without timeout-based reads.
|
||||
client.sync()
|
||||
assert not any(local(e) == 'presence' and e.get('from', '').startswith(room + '/')
|
||||
for e in client.queue), [ET.tostring(e) for e in client.queue]
|
||||
client.send(f"<message to='{room}' type='groupchat' id='not-joined'><body>must not route</body></message>")
|
||||
response = client.take(lambda e: local(e) == 'message' and e.get('id') == 'not-joined')
|
||||
assert response.get('type') == 'error', ET.tostring(response)
|
||||
assert response.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}not-acceptable') is not None, ET.tostring(response)
|
||||
|
||||
|
||||
def start(binary, config, port, log):
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
return server
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
server.kill()
|
||||
server.wait()
|
||||
raise AssertionError('server did not start')
|
||||
|
||||
|
||||
def stop(server):
|
||||
server.terminate()
|
||||
try:
|
||||
code = server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
raise AssertionError('SIGTERM did not complete orderly shutdown')
|
||||
assert code == 0, f'SIGTERM shutdown exited {code}'
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-muc-restart-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||||
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj',
|
||||
'/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps({'global': {
|
||||
'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port],
|
||||
's2s_ports': [], 'http_ports': [], 'https_ports': [],
|
||||
'modules_enabled': ['tls', 'saslauth', 'ping', 'disco'],
|
||||
}, 'hosts': {
|
||||
'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
||||
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}},
|
||||
'rooms.localhost': {'component': 'muc', 'options': {}},
|
||||
}}))
|
||||
for user in ('alice', 'bob', 'carol'):
|
||||
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'],
|
||||
input='secret\n', text=True, check=True, capture_output=True)
|
||||
clients = []
|
||||
server = None
|
||||
with (root/'server.log').open('w+') as log:
|
||||
try:
|
||||
server = start(binary, config, port, log)
|
||||
alice = Client(port, root/'cert.pem', 'alice', 'secret', 'desktop')
|
||||
bob = Client(port, root/'cert.pem', 'bob', 'secret', 'desktop')
|
||||
clients.extend((alice, bob))
|
||||
alice.available()
|
||||
bob.available()
|
||||
for index, room in enumerate(ROOMS):
|
||||
occupant(join(alice, room, 'Alice'), 'moderator', 'owner')
|
||||
fields = ("<field var='FORM_TYPE'><value>http://jabber.org/protocol/muc#roomconfig</value></field>"
|
||||
f"<field var='muc#roomconfig_persistentroom'><value>{int(index == 0)}</value></field>")
|
||||
iq(alice, f'configure-{index}', f"<query xmlns='{MUC}#owner'><x xmlns='jabber:x:data' type='submit'>{fields}</x></query>", room)
|
||||
occupant(join(bob, room, 'Bob'), 'participant', 'none')
|
||||
iq(alice, f'moderator-{index}', f"<query xmlns='{MUC}#admin'><item nick='Bob' role='moderator'/></query>", room)
|
||||
bob.take(lambda e: local(e) == 'presence' and e.get('from') == room + '/Bob'
|
||||
and e.find(f'{{{USER}}}x/{{{USER}}}item').get('role') == 'moderator')
|
||||
alice.sync()
|
||||
bob.sync()
|
||||
stop(server)
|
||||
server = None
|
||||
for client in clients:
|
||||
client.close()
|
||||
clients.clear()
|
||||
|
||||
server = start(binary, config, port, log)
|
||||
other_resource = Client(port, root/'cert.pem', 'alice', 'secret', 'phone')
|
||||
carol = Client(port, root/'cert.pem', 'carol', 'secret', 'desktop')
|
||||
clients.extend((other_resource, carol))
|
||||
other_resource.available()
|
||||
carol.available()
|
||||
for room in ROOMS:
|
||||
not_joined(other_resource, room)
|
||||
not_joined(carol, room)
|
||||
|
||||
bob = Client(port, root/'cert.pem', 'bob', 'secret', 'desktop')
|
||||
alice = Client(port, root/'cert.pem', 'alice', 'secret', 'desktop')
|
||||
clients.extend((bob, alice))
|
||||
bob.send('<presence><show>chat</show><status>after restart</status></presence>')
|
||||
for room in ROOMS:
|
||||
packet = presence(bob, room, 'Bob')
|
||||
occupant(packet, 'moderator', 'none')
|
||||
alice.send('<presence><show>away</show><status>restored owner</status></presence>')
|
||||
for room in ROOMS:
|
||||
occupant(presence(alice, room, 'Alice'), 'moderator', 'owner')
|
||||
packet = presence(bob, room, 'Alice')
|
||||
occupant(packet, 'moderator', 'owner')
|
||||
assert packet.find('{jabber:client}show').text == 'away', ET.tostring(packet)
|
||||
assert packet.find('{jabber:client}status').text == 'before restart', ET.tostring(packet)
|
||||
alice.send(f"<presence to='{room}/Alice'><show>chat</show><status>updated after recovery</status></presence>")
|
||||
updated = presence(bob, room, 'Alice')
|
||||
assert updated.find('{jabber:client}status').text == 'updated after recovery', ET.tostring(updated)
|
||||
carol.send(f"<presence to='{room}/Alice'><x xmlns='{MUC}'/></presence>")
|
||||
denied = presence(carol, room, 'Alice')
|
||||
assert denied.get('type') == 'error', ET.tostring(denied)
|
||||
assert denied.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}conflict') is not None, ET.tostring(denied)
|
||||
text = 'after recovery ' + room
|
||||
alice.send(f"<message to='{room}' type='groupchat'><body>{text}</body></message>")
|
||||
assert bob.body(text).get('from') == room + '/Alice'
|
||||
assert alice.body(text).get('from') == room + '/Alice'
|
||||
# Explicit joins remain valid for another authenticated resource.
|
||||
occupant(join(other_resource, room, 'Phone'), 'moderator', 'owner')
|
||||
stop(server)
|
||||
server = None
|
||||
finally:
|
||||
if server is not None and server.poll() is None:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
for client in clients:
|
||||
client.close()
|
||||
if sys.exc_info()[0] is not None:
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
print('PASS: STARTTLS MUC SIGTERM restart restores live temporary/persistent rooms, exact full-JID membership, roles, presence and groupchat routing; rejects automatic cross-resource/account claims and nickname theft')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,106 @@
|
||||
use rust_xmpp::{muc::Muc, protocol::ModuleResult, storage};
|
||||
use serde_json::json;
|
||||
const HOST: &str = "rooms.example.org";
|
||||
const ROOM: &str = "live@rooms.example.org";
|
||||
const ALICE: &str = "alice@example.org/laptop";
|
||||
fn send(m: &mut Muc, jid: &str, packet: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(packet).unwrap();
|
||||
m.handle(jid, doc.root_element()).unwrap().unwrap()
|
||||
}
|
||||
fn initial(root: &std::path::Path) -> Muc {
|
||||
let mut m = Muc::new(root.into(), HOST.into());
|
||||
m.set_defaults(json!({"persistent": true, "password":"private-pass"}));
|
||||
send(
|
||||
&mut m,
|
||||
ALICE,
|
||||
&format!(
|
||||
"<presence to='{ROOM}/Alice'><x xmlns='http://jabber.org/protocol/muc'><password>private-pass</password></x><status>saved status</status></presence>"
|
||||
),
|
||||
);
|
||||
m
|
||||
}
|
||||
#[test]
|
||||
fn repeated_restart_keeps_dormant_claim_and_deadline_without_ghost_routing() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = initial(root.path());
|
||||
m.checkpoint_live().unwrap();
|
||||
let before = rust_xmpp::muc_recovery::load_checkpoint_time(root.path(), HOST, ROOM).unwrap();
|
||||
drop(m);
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
m.prepare_recovery().unwrap();
|
||||
assert!(
|
||||
m.restore_for_session("alice@example.org/phone")
|
||||
.unwrap()
|
||||
.replies
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
m.restore_for_session("eve@example.org/laptop")
|
||||
.unwrap()
|
||||
.replies
|
||||
.is_empty()
|
||||
);
|
||||
m.checkpoint_live().unwrap();
|
||||
assert_eq!(
|
||||
rust_xmpp::muc_recovery::load_checkpoint_time(root.path(), HOST, ROOM).unwrap(),
|
||||
before
|
||||
);
|
||||
drop(m);
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
m.prepare_recovery().unwrap();
|
||||
let restored = m.restore_for_session(ALICE).unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.replies
|
||||
.iter()
|
||||
.any(|p| p.contains("saved status") && p.contains("110"))
|
||||
);
|
||||
assert!(restored.replies.iter().all(|p| !p.contains("private-pass")));
|
||||
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
|
||||
// Simulate a later crash: no clean checkpoint. A consumed on-disk proof
|
||||
// must not revive a prior moderator grant on another process start.
|
||||
drop(m);
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
m.prepare_recovery().unwrap();
|
||||
assert!(m.restore_for_session(ALICE).unwrap().replies.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn changed_configuration_invalidates_auto_recovery_without_disclosing_password() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = initial(root.path());
|
||||
m.checkpoint_live().unwrap();
|
||||
drop(m);
|
||||
storage::update(root.path(), HOST, Some("live"), "config", |v| {
|
||||
let mut data = v.get("_data").cloned().unwrap();
|
||||
data.set("password", "new-private-password".into());
|
||||
v.set("_data", data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
m.prepare_recovery().unwrap();
|
||||
let result = m.restore_for_session(ALICE).unwrap();
|
||||
assert!(result.replies.is_empty() && result.deliveries.is_empty());
|
||||
let denied = send(&mut m, ALICE, &format!("<presence to='{ROOM}/Alice'/>"));
|
||||
assert!(denied.replies.iter().any(|p| p.contains("not-authorized")));
|
||||
assert!(
|
||||
denied
|
||||
.replies
|
||||
.iter()
|
||||
.all(|p| !p.contains("new-private-password"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_disconnect_does_not_delete_dormant_temporary_room() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
send(&mut m, ALICE, &format!("<presence to='{ROOM}/Alice'/>"));
|
||||
m.checkpoint_live().unwrap();
|
||||
drop(m);
|
||||
let mut m = Muc::new(root.path().into(), HOST.into());
|
||||
m.prepare_recovery().unwrap();
|
||||
m.disconnect("unrelated@example.org/mobile");
|
||||
let restored = m.restore_for_session(ALICE).unwrap();
|
||||
assert!(restored.replies.iter().any(|p| p.contains("110")));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Native freeze(true)/restore differential coverage for MUC occupant checkpoints.
|
||||
use rust_xmpp::{
|
||||
muc::Muc,
|
||||
muc_recovery::{OccupantSnapshot, load_room, save_room},
|
||||
};
|
||||
|
||||
const DOMAIN: &str = "rooms.example.org";
|
||||
const ROOM: &str = "room@rooms.example.org";
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn actual_prosody_restores_rust_checkpoint_and_rust_reads_native_freeze() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
// Let the real room implementation create its normal configuration/index.
|
||||
let mut muc = Muc::new(root.path().into(), DOMAIN.into());
|
||||
muc.set_defaults(serde_json::json!({"persistent":true,"whois":"moderators"}));
|
||||
let join = roxmltree::Document::parse(
|
||||
"<presence to='room@rooms.example.org/Alice'><status>Rust desktop</status></presence>",
|
||||
)
|
||||
.unwrap();
|
||||
muc.handle("alice@example.org/desktop", join.root_element())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
drop(muc);
|
||||
|
||||
let snapshots = vec![
|
||||
OccupantSnapshot {
|
||||
jid: "alice@example.org/desktop".into(),
|
||||
nick: "Alice".into(),
|
||||
role: "moderator".into(),
|
||||
presence_payload: "<show>away</show><status>Rust desktop</status>".into(),
|
||||
},
|
||||
OccupantSnapshot {
|
||||
jid: "alice@example.org/mobile".into(),
|
||||
nick: "Alice".into(),
|
||||
role: "moderator".into(),
|
||||
presence_payload: "<status>Rust mobile</status><priority>3</priority>".into(),
|
||||
},
|
||||
OccupantSnapshot {
|
||||
jid: "bob@example.org/native".into(),
|
||||
nick: "Bob".into(),
|
||||
role: "participant".into(),
|
||||
presence_payload: "<status>Rust Bob</status>".into(),
|
||||
},
|
||||
];
|
||||
save_room(root.path(), DOMAIN, ROOM, &snapshots).unwrap();
|
||||
|
||||
let bootstrap = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/prosody_compat/oracle.lua"
|
||||
))
|
||||
.unwrap();
|
||||
let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap();
|
||||
let script = format!(
|
||||
"{bootstrap}\n{}",
|
||||
r#"
|
||||
config.set('rooms.example.org','component_module','muc');
|
||||
assert(require('prosody.core.hostmanager').activate('rooms.example.org'));
|
||||
local muc=assert(mm.get_module('rooms.example.org','muc'));
|
||||
local room=assert(muc.get_room_from_jid('room@rooms.example.org'));
|
||||
assert(room:get_persistent());
|
||||
local alice=assert(room:get_occupant_by_nick('room@rooms.example.org/Alice'));
|
||||
assert(alice.bare_jid=='alice@example.org' and alice.role=='moderator');
|
||||
local sessions=0;
|
||||
for jid,presence in alice:each_session() do
|
||||
sessions=sessions+1;
|
||||
assert(presence.attr.from=='room@rooms.example.org/Alice');
|
||||
if jid=='alice@example.org/desktop' then
|
||||
assert(presence:get_child_text('show')=='away');
|
||||
assert(presence:get_child_text('status')=='Rust desktop');
|
||||
elseif jid=='alice@example.org/mobile' then
|
||||
assert(presence:get_child_text('status')=='Rust mobile');
|
||||
assert(presence:get_child_text('priority')=='3');
|
||||
else error('unexpected Alice session '..jid); end
|
||||
end
|
||||
assert(sessions==2,'native restore lost a resource');
|
||||
local bob=assert(room:get_occupant_by_nick('room@rooms.example.org/Bob'));
|
||||
assert(bob.bare_jid=='bob@example.org' and bob.role=='participant');
|
||||
assert(bob:get_presence():get_child_text('status')=='Rust Bob');
|
||||
|
||||
-- Change the native occupant and serialize through upstream freeze(true).
|
||||
bob.role='visitor';
|
||||
bob:set_session('bob@example.org/native',st.presence({xmlns='jabber:client'})
|
||||
:tag('show'):text('dnd'):up():tag('status'):text('Native Bob & ready'):up());
|
||||
room:save_occupant(bob);
|
||||
local frozen,state=room:freeze(true);
|
||||
assert(state['room@rooms.example.org/Alice'].role=='moderator');
|
||||
assert(state['room@rooms.example.org/Bob'].role=='visitor');
|
||||
assert(state['alice@example.org/desktop'].name=='presence');
|
||||
assert(state['alice@example.org/mobile'].name=='presence');
|
||||
assert(room:save(nil,true));
|
||||
print('NATIVE_MUC_STATE_ROUNDTRIP_OK');
|
||||
"#
|
||||
);
|
||||
let script_path = root.path().join("native-muc-state.lua");
|
||||
std::fs::write(&script_path, script).unwrap();
|
||||
let output = std::process::Command::new(
|
||||
std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()),
|
||||
)
|
||||
.arg(&script_path)
|
||||
.arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap())
|
||||
.arg(root.path())
|
||||
.arg("muc-state")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("NATIVE_MUC_STATE_ROUNDTRIP_OK"));
|
||||
|
||||
let recovered = load_room(root.path(), DOMAIN, ROOM).unwrap();
|
||||
assert_eq!(recovered.len(), 3, "{recovered:?}");
|
||||
for original in &snapshots[..2] {
|
||||
assert_eq!(
|
||||
recovered.iter().find(|o| o.jid == original.jid),
|
||||
Some(original),
|
||||
"native freeze must preserve both Alice sessions"
|
||||
);
|
||||
}
|
||||
let bob = recovered
|
||||
.iter()
|
||||
.find(|o| o.jid == "bob@example.org/native")
|
||||
.unwrap();
|
||||
assert_eq!(bob.nick, "Bob");
|
||||
assert_eq!(bob.role, "visitor");
|
||||
let wrapped = format!("<presence>{}</presence>", bob.presence_payload);
|
||||
let payload = roxmltree::Document::parse(&wrapped).unwrap();
|
||||
let text = |name: &str| {
|
||||
payload
|
||||
.root_element()
|
||||
.children()
|
||||
.find(|n| n.has_tag_name(name))
|
||||
.and_then(|n| n.text())
|
||||
};
|
||||
assert_eq!(text("show"), Some("dnd"));
|
||||
assert_eq!(text("status"), Some("Native Bob & ready"));
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Wire-level PEP requests used by Conversations/monocles. This exercises the
|
||||
//! server handlers; it does not claim Android device interoperability validation.
|
||||
use rust_xmpp::{
|
||||
auth,
|
||||
protocol::ModuleResult,
|
||||
pubsub,
|
||||
storage::{self, LuaValue as V},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
fn request(root: &Path, actor: &str, stanza: &str) -> ModuleResult {
|
||||
let doc = roxmltree::Document::parse(stanza).unwrap();
|
||||
pubsub::handle(root, "example.test", actor, doc.root_element())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
fn account(root: &Path) {
|
||||
auth::create(root, "example.test", "alice", "secret", "SHA-256").unwrap();
|
||||
}
|
||||
fn publish(root: &Path, node: &str, id: &str, payload: &str, fields: &str) -> ModuleResult {
|
||||
request(
|
||||
root,
|
||||
"alice@example.test/phone",
|
||||
&format!(
|
||||
"<iq type='set' id='publish'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='{node}'><item id='{id}'>{payload}</item></publish><publish-options><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field>{fields}</x></publish-options></pubsub></iq>"
|
||||
),
|
||||
)
|
||||
}
|
||||
fn assert_payload(stanza: &str, payload: &str) {
|
||||
fn same(actual: roxmltree::Node<'_, '_>, expected: roxmltree::Node<'_, '_>) {
|
||||
assert_eq!(actual.tag_name(), expected.tag_name());
|
||||
let attrs = |n: roxmltree::Node<'_, '_>| {
|
||||
n.attributes()
|
||||
.map(|a| {
|
||||
(
|
||||
a.namespace().unwrap_or("").to_owned(),
|
||||
a.name().to_owned(),
|
||||
a.value().to_owned(),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
assert_eq!(attrs(actual), attrs(expected));
|
||||
let actual: Vec<_> = actual
|
||||
.children()
|
||||
.filter(|n| n.is_element() || n.is_text())
|
||||
.collect();
|
||||
let expected: Vec<_> = expected
|
||||
.children()
|
||||
.filter(|n| n.is_element() || n.is_text())
|
||||
.collect();
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (a, e) in actual.into_iter().zip(expected) {
|
||||
if e.is_element() {
|
||||
same(a, e);
|
||||
} else {
|
||||
assert_eq!(a.text(), e.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
let actual = roxmltree::Document::parse(stanza).unwrap();
|
||||
let expected = roxmltree::Document::parse(payload).unwrap();
|
||||
let e = expected.root_element();
|
||||
let a = actual
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name() == e.tag_name())
|
||||
.unwrap();
|
||||
same(a, e);
|
||||
}
|
||||
const OPEN: &str = "<field var='pubsub#access_model'><value>open</value></field>";
|
||||
|
||||
#[test]
|
||||
fn missing_own_omemo_bundle_returns_recoverable_item_not_found() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
// Monocles latches pepBroken for other errors in this initial lookup.
|
||||
for node in [
|
||||
"urn:xmpp:omemo:2:bundles",
|
||||
"eu.siacs.conversations.axolotl.bundles:42",
|
||||
] {
|
||||
let result = request(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
&format!(
|
||||
"<iq type='get' id='bundle' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'><item id='42'/></items></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&result.replies[0]).unwrap();
|
||||
assert_eq!(doc.root_element().attribute("type"), Some("error"));
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "item-not-found")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_and_omemo_publish_options_support_readback_and_caps_notifications() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
let presence = roxmltree::Document::parse("<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='urn:test:client' ver='caps'/></presence>").unwrap();
|
||||
let caps = pubsub::on_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/laptop",
|
||||
presence.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
let caps_doc = roxmltree::Document::parse(&caps.deliveries[0].1).unwrap();
|
||||
let id = caps_doc.root_element().attribute("id").unwrap();
|
||||
let answer = format!(
|
||||
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='urn:xmpp:avatar:metadata+notify'/><feature var='eu.siacs.conversations.axolotl.devicelist+notify'/></query></iq>"
|
||||
);
|
||||
let answer = roxmltree::Document::parse(&answer).unwrap();
|
||||
pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/laptop",
|
||||
answer.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for (node, id, payload, notify) in [
|
||||
(
|
||||
"urn:xmpp:avatar:data",
|
||||
"avatar-hash",
|
||||
"<data xmlns='urn:xmpp:avatar:data'>aW1hZ2U=</data>",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"urn:xmpp:avatar:metadata",
|
||||
"avatar-hash",
|
||||
"<metadata xmlns='urn:xmpp:avatar:metadata'><info id='avatar-hash' bytes='5' type='image/png'/></metadata>",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"eu.siacs.conversations.axolotl.devicelist",
|
||||
"current",
|
||||
"<list xmlns='eu.siacs.conversations.axolotl'><device id='42'/></list>",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"urn:xmpp:omemo:2:bundles",
|
||||
"42",
|
||||
"<bundle xmlns='urn:xmpp:omemo:2'><ik>test-key</ik></bundle>",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let result = publish(d.path(), node, id, payload, OPEN);
|
||||
assert!(
|
||||
result.replies[0].contains("type='result'"),
|
||||
"{:?}",
|
||||
result.replies
|
||||
);
|
||||
assert_eq!(result.deliveries.len(), usize::from(notify));
|
||||
if notify {
|
||||
assert_eq!(result.deliveries[0].0, "alice@example.test/laptop");
|
||||
assert_payload(&result.deliveries[0].1, payload);
|
||||
}
|
||||
let read = request(
|
||||
d.path(),
|
||||
"bob@remote.test/phone",
|
||||
&format!(
|
||||
"<iq type='get' id='read' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'><item id='{id}'/></items></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
assert_payload(&read.replies[0], payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_options_match_implicit_notification_defaults_on_old_nodes() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
let mut nodes = V::table();
|
||||
let mut node = V::table();
|
||||
node.set("name", "urn:test:existing".into());
|
||||
// Native and earlier Rust stores can omit default-valued config fields.
|
||||
node.set("config", V::table());
|
||||
nodes.set("urn:test:existing", node);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "pep", &nodes).unwrap();
|
||||
let fields = [
|
||||
"deliver_notifications",
|
||||
"deliver_payloads",
|
||||
"notify_delete",
|
||||
"notify_retract",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| format!("<field var='pubsub#{key}'><value>true</value></field>"))
|
||||
.collect::<String>();
|
||||
let result = publish(
|
||||
d.path(),
|
||||
"urn:test:existing",
|
||||
"current",
|
||||
"<data xmlns='urn:test'>new</data>",
|
||||
&fields,
|
||||
);
|
||||
assert!(
|
||||
result.replies[0].contains("type='result'"),
|
||||
"{:?}",
|
||||
result.replies
|
||||
);
|
||||
let result = publish(
|
||||
d.path(),
|
||||
"urn:test:existing",
|
||||
"current",
|
||||
"<data xmlns='urn:test'>bad</data>",
|
||||
"<field var='pubsub#deliver_payloads'><value>false</value></field>",
|
||||
);
|
||||
assert!(result.replies[0].contains("precondition-not-met"));
|
||||
let read = request(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
"<iq type='get' id='read'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='urn:test:existing'/></pubsub></iq>",
|
||||
);
|
||||
assert!(read.replies[0].contains(">new</data>"));
|
||||
assert!(!read.replies[0].contains(">bad</data>"));
|
||||
}
|
||||
|
||||
fn owner_config(root: &Path, actor: &str, node: &str, fields: &str) -> ModuleResult {
|
||||
request(
|
||||
root,
|
||||
actor,
|
||||
&format!(
|
||||
"<iq type='set' id='configure'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='{node}'><x xmlns='jabber:x:data' type='submit'>{fields}</x></configure></pubsub></iq>"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_native_configuration_can_roundtrip_access_model_edit() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
let path = storage::path(d.path(), "example.test", Some("alice"), "pep");
|
||||
storage::atomic_write(&path, b"return { ['urn:test:migrated']={name='urn:test:migrated';config={access_model='presence';notification_type='headline';itemreply='none';payload_type='urn:test:data';type='urn:stale:alias';publish_model='publishers';max_items=1};subscribers={};affiliations={['alice@example.test']='owner'}}};").unwrap();
|
||||
let get = request(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
"<iq type='get' id='get-config'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='urn:test:migrated'/></pubsub></iq>",
|
||||
);
|
||||
assert!(!get.replies[0].contains("pubsub#payload_type"));
|
||||
assert!(get.replies[0].contains("pubsub#type"));
|
||||
let doc = roxmltree::Document::parse(&get.replies[0]).unwrap();
|
||||
assert_eq!(
|
||||
doc.descendants()
|
||||
.filter(|n| n.attribute("var") == Some("pubsub#type"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let form = doc
|
||||
.descendants()
|
||||
.find(|n| n.has_tag_name(("jabber:x:data", "x")))
|
||||
.unwrap();
|
||||
let form = rust_xmpp::protocol::xml(form)
|
||||
.replace("type='form'", "type='submit'")
|
||||
.replace(">presence<", ">open<");
|
||||
let set = request(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
&format!(
|
||||
"<iq type='set' id='set-config'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='urn:test:migrated'>{form}</configure></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
set.replies[0].contains("type='result'"),
|
||||
"{:?}",
|
||||
set.replies
|
||||
);
|
||||
let metadata = storage::load(d.path(), "example.test", Some("alice"), "pep")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let config = metadata
|
||||
.get("urn:test:migrated")
|
||||
.unwrap()
|
||||
.get("config")
|
||||
.unwrap();
|
||||
assert_eq!(config.get("access_model").and_then(V::as_str), Some("open"));
|
||||
assert!(config.get("type").is_none());
|
||||
assert_eq!(
|
||||
config.get("payload_type").and_then(V::as_str),
|
||||
Some("urn:test:data")
|
||||
);
|
||||
assert_eq!(
|
||||
config.get("notification_type").and_then(V::as_str),
|
||||
Some("headline")
|
||||
);
|
||||
assert_eq!(config.get("itemreply").and_then(V::as_str), Some("none"));
|
||||
for (key, value) in [
|
||||
("notification_type", "chat"),
|
||||
("itemreply", "everyone"),
|
||||
("publish_model", "anyone"),
|
||||
] {
|
||||
let bad = owner_config(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
"urn:test:migrated",
|
||||
&format!("<field var='pubsub#{key}'><value>{value}</value></field>"),
|
||||
);
|
||||
assert!(
|
||||
bad.replies[0].contains("not-acceptable"),
|
||||
"{:?}",
|
||||
bad.replies
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_itemreply_controls_publisher_visibility_in_reads_and_replays() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
for reply in ["none", "owner", "publisher"] {
|
||||
let fields = format!("{OPEN}<field var='pubsub#itemreply'><value>{reply}</value></field>");
|
||||
let node = format!("urn:test:{reply}");
|
||||
assert!(
|
||||
publish(
|
||||
d.path(),
|
||||
&node,
|
||||
"current",
|
||||
"<data xmlns='urn:test'>value</data>",
|
||||
&fields
|
||||
)
|
||||
.replies[0]
|
||||
.contains("type='result'")
|
||||
);
|
||||
let read = request(
|
||||
d.path(),
|
||||
"bob@remote.test/phone",
|
||||
&format!(
|
||||
"<iq type='get' id='read' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='{node}'/></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
let replay = request(
|
||||
d.path(),
|
||||
"bob@remote.test/phone",
|
||||
&format!(
|
||||
"<iq type='set' id='sub' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><subscribe node='{node}' jid='bob@remote.test/phone'/></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
assert_eq!(replay.deliveries.len(), 1);
|
||||
let event = publish(
|
||||
d.path(),
|
||||
&node,
|
||||
"current",
|
||||
"<data xmlns='urn:test'>update</data>",
|
||||
&fields,
|
||||
);
|
||||
assert_eq!(event.deliveries.len(), 1);
|
||||
for stanza in [
|
||||
&read.replies[0],
|
||||
&replay.deliveries[0].1,
|
||||
&event.deliveries[0].1,
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(stanza).unwrap();
|
||||
let item = doc
|
||||
.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().name() == "item")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
item.attribute("publisher"),
|
||||
(reply == "publisher").then_some("alice@example.test")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_component_notification_type_applies_to_events_and_subscription_replay() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let call = |actor: &str, xml: &str| {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
pubsub::handle_component(
|
||||
d.path(),
|
||||
"pubsub.example.test",
|
||||
actor,
|
||||
&["alice@example.test".into()],
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
for kind in ["normal", "headline"] {
|
||||
let result = call(
|
||||
"alice@example.test/phone",
|
||||
&format!(
|
||||
"<iq type='set' id='create'><pubsub xmlns='http://jabber.org/protocol/pubsub'><create node='{kind}'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#notification_type'><value>{kind}</value></field><field var='pubsub#send_last_published_item'><value>on_sub</value></field></x></configure></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
assert!(result.replies[0].contains("type='result'"));
|
||||
let publish = format!(
|
||||
"<iq type='set' id='publish'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='{kind}'><item id='one'><data xmlns='urn:test'/></item></publish></pubsub></iq>"
|
||||
);
|
||||
call("alice@example.test/phone", &publish);
|
||||
let replay = call(
|
||||
"bob@remote.test/phone",
|
||||
&format!(
|
||||
"<iq type='set' id='sub'><pubsub xmlns='http://jabber.org/protocol/pubsub'><subscribe node='{kind}' jid='bob@remote.test/phone'/></pubsub></iq>"
|
||||
),
|
||||
);
|
||||
let event = call("alice@example.test/phone", &publish);
|
||||
for result in [replay, event] {
|
||||
assert_eq!(result.deliveries.len(), 1);
|
||||
let doc = roxmltree::Document::parse(&result.deliveries[0].1).unwrap();
|
||||
assert_eq!(doc.root_element().attribute("type"), Some(kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_open_and_subscriber_publish_models_preserve_outcast_and_blocking_checks() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
auth::create(d.path(), "example.test", "bob", "secret", "SHA-256").unwrap();
|
||||
for model in ["open", "subscribers"] {
|
||||
let node = format!("urn:test:{model}");
|
||||
let fields =
|
||||
format!("{OPEN}<field var='pubsub#publish_model'><value>{model}</value></field>");
|
||||
assert!(
|
||||
publish(
|
||||
d.path(),
|
||||
&node,
|
||||
"current",
|
||||
"<data xmlns='urn:test'/>",
|
||||
&fields
|
||||
)
|
||||
.replies[0]
|
||||
.contains("type='result'")
|
||||
);
|
||||
let write = format!(
|
||||
"<iq type='set' id='write' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='{node}'><item id='bob'><data xmlns='urn:test'/></item></publish></pubsub></iq>"
|
||||
);
|
||||
let initial = request(d.path(), "bob@example.test/phone", &write);
|
||||
assert_eq!(
|
||||
initial.replies[0].contains("type='result'"),
|
||||
model == "open"
|
||||
);
|
||||
let sub = format!(
|
||||
"<iq type='set' id='sub' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><subscribe node='{node}' jid='bob@example.test/phone'/></pubsub></iq>"
|
||||
);
|
||||
request(d.path(), "bob@example.test/phone", &sub);
|
||||
assert!(
|
||||
request(d.path(), "bob@example.test/phone", &write).replies[0]
|
||||
.contains("type='result'")
|
||||
);
|
||||
let mut metadata = storage::load(d.path(), "example.test", Some("alice"), "pep")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut entry = metadata.get(&node).unwrap().clone();
|
||||
let mut affiliations = entry.get("affiliations").unwrap().clone();
|
||||
affiliations.set("bob@example.test", "outcast".into());
|
||||
entry.set("affiliations", affiliations);
|
||||
metadata.set(&node, entry);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap();
|
||||
assert!(
|
||||
request(d.path(), "bob@example.test/phone", &write).replies[0].contains("forbidden")
|
||||
);
|
||||
let mut metadata = storage::load(d.path(), "example.test", Some("alice"), "pep")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut entry = metadata.get(&node).unwrap().clone();
|
||||
entry.set("affiliations", V::table());
|
||||
metadata.set(&node, entry);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap();
|
||||
let mut blocked = V::table();
|
||||
blocked.set("bob@example.test", V::Bool(true));
|
||||
storage::save(
|
||||
d.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"blocklist",
|
||||
&blocked,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
request(d.path(), "bob@example.test/phone", &write).replies[0].contains("forbidden")
|
||||
);
|
||||
storage::save(
|
||||
d.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"blocklist",
|
||||
&V::table(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monocles/Conversations PEP request sequences over real loopback STARTTLS.
|
||||
|
||||
Usage: python3 tests/pep_client_network.py target/debug/rust-xmpp
|
||||
Payload shapes follow Monocles IqGenerator and AxolotlService: missing own
|
||||
bundle must return item-not-found (other errors latch isPepBroken), publishes
|
||||
omit `to`, and open-access preconditions recover through owner configuration.
|
||||
Key bytes are opaque transport fixtures, not usable cryptographic bundles.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from push_discovery import Client
|
||||
|
||||
PUBSUB = 'http://jabber.org/protocol/pubsub'
|
||||
OWNER = PUBSUB + '#owner'
|
||||
ERROR = PUBSUB + '#errors'
|
||||
DATA = 'jabber:x:data'
|
||||
STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas'
|
||||
LEGACY = 'eu.siacs.conversations.axolotl'
|
||||
OMEMO = 'urn:xmpp:omemo:2'
|
||||
BUNDLES = OMEMO + ':bundles'
|
||||
|
||||
|
||||
def form(kind, fields):
|
||||
values = {'FORM_TYPE': PUBSUB + '#' + kind, **fields}
|
||||
return "<x xmlns='jabber:x:data' type='submit'>" + ''.join(
|
||||
f"<field var='{key}'><value>{value}</value></field>"
|
||||
for key, value in values.items()) + '</x>'
|
||||
|
||||
|
||||
def result(response):
|
||||
assert response.get('type') == 'result', ET.tostring(response)
|
||||
return response
|
||||
|
||||
|
||||
def condition(response, namespace, name):
|
||||
assert response.get('type') == 'error', ET.tostring(response)
|
||||
assert next(response.iter(f'{{{namespace}}}{name}'), None) is not None, ET.tostring(response)
|
||||
|
||||
|
||||
def fetch(client, node, item=None, identifier='fetch', target='alice@localhost'):
|
||||
selector = '' if item is None else f"<item id='{item}'/>"
|
||||
return client.iq(identifier, f"<pubsub xmlns='{PUBSUB}'><items node='{node}'>{selector}</items></pubsub>", target)
|
||||
|
||||
|
||||
def publish(client, node, item, payload, options=None, identifier='publish'):
|
||||
options_xml = '' if options is None else '<publish-options>' + form('publish-options', options) + '</publish-options>'
|
||||
# IqGenerator.publish deliberately does not set a destination.
|
||||
return client.iq(identifier, f"<pubsub xmlns='{PUBSUB}'><publish node='{node}'><item id='{item}'>{payload}</item></publish>{options_xml}</pubsub>", typ='set')
|
||||
|
||||
|
||||
def check_items(client):
|
||||
for device in ('123', '456'):
|
||||
response = result(fetch(client, BUNDLES, device, 'bundle-' + device))
|
||||
items = list(response.iter(f'{{{PUBSUB}}}item'))
|
||||
assert [item.get('id') for item in items] == [device], ET.tostring(response)
|
||||
bundle = next(response.iter(f'{{{OMEMO}}}bundle'))
|
||||
assert bundle.find(f'{{{OMEMO}}}spk').text == 'ZmFrZS1rZXk=' + device, ET.tostring(response)
|
||||
for node, namespace, element in ((LEGACY + '.devicelist', LEGACY, 'list'), (OMEMO + ':devices', OMEMO, 'devices')):
|
||||
response = result(fetch(client, node))
|
||||
devices = next(response.iter(f'{{{namespace}}}{element}'))
|
||||
assert {d.get('id') for d in devices} == {'123', '456'}, ET.tostring(response)
|
||||
|
||||
|
||||
def sequence(client):
|
||||
# AxolotlService's first own-bundle fetch must never become service-unavailable.
|
||||
condition(fetch(client, BUNDLES, '123'), STANZAS, 'item-not-found')
|
||||
for node in (LEGACY + '.bundles:123', 'urn:missing:pep'):
|
||||
condition(fetch(client, node), STANZAS, 'item-not-found')
|
||||
for device in ('123', '456'):
|
||||
bundle = f"<bundle xmlns='{OMEMO}'><spk id='1'>ZmFrZS1rZXk={device}</spk><spks>c2ln</spks><ik>aWs=</ik><prekeys><pk id='1'>cGs=</pk></prekeys></bundle>"
|
||||
# OMEMO2 shares a node between devices; retain both records explicitly.
|
||||
options = {'pubsub#access_model': 'open', 'pubsub#max_items': 'max'}
|
||||
result(publish(client, BUNDLES, device, bundle, options))
|
||||
for node, namespace, element in ((LEGACY + '.devicelist', LEGACY, 'list'), (OMEMO + ':devices', OMEMO, 'devices')):
|
||||
payload = f"<{element} xmlns='{namespace}'><device id='123'/><device id='456'/></{element}>"
|
||||
# Existing/migrated presence node: Monocles retries after pushing config.
|
||||
response = publish(client, node, 'current', payload, {'pubsub#access_model': 'open'})
|
||||
if response.get('type') == 'error':
|
||||
condition(response, ERROR, 'precondition-not-met')
|
||||
response = result(client.iq('get-config', f"<pubsub xmlns='{OWNER}'><configure node='{node}'/></pubsub>"))
|
||||
x = next(response.iter(f'{{{DATA}}}x'))
|
||||
x.set('type', 'submit')
|
||||
field = next(f for f in x if f.get('var') == 'pubsub#access_model')
|
||||
field.find(f'{{{DATA}}}value').text = 'open'
|
||||
result(client.iq('set-config', f"<pubsub xmlns='{OWNER}'><configure node='{node}'>" + ET.tostring(x, encoding='unicode') + '</configure></pubsub>', typ='set'))
|
||||
result(publish(client, node, 'current', payload, {'pubsub#access_model': 'open'}))
|
||||
check_items(client)
|
||||
# A missing item on an existing bundle node must also be benign to clients.
|
||||
condition(fetch(client, BUNDLES, '999'), STANZAS, 'item-not-found')
|
||||
# A PEP routing fix must keep another user's node protected.
|
||||
condition(client.iq('other-publish', f"<pubsub xmlns='{PUBSUB}'><publish node='{BUNDLES}'><item id='123'><bundle xmlns='{OMEMO}'/></item></publish></pubsub>", 'bob@localhost', 'set'), STANZAS, 'forbidden')
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-pep-clients-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for migrated in (False, True):
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
data = root / ('migrated' if migrated else 'fresh')
|
||||
config = root / 'config.json'
|
||||
config.write_text(json.dumps({'global': {'data_path': str(data), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}))
|
||||
for user in ('alice', 'bob'):
|
||||
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
|
||||
if migrated:
|
||||
# Native-like metadata includes fields which previously failed whole-form resubmission.
|
||||
store = data / 'localhost' / 'pep' / 'alice.dat'
|
||||
store.parent.mkdir(parents=True, exist_ok=True)
|
||||
store.write_text('return { ' + ', '.join(f'["{node}"] = {{ config = {{ access_model = "presence", max_items = 1, persist_items = true, notification_type = "normal", itemreply = "publisher", payload_type = "{LEGACY}" }} }}' for node in (LEGACY + '.devicelist', OMEMO + ':devices')) + ' }\n')
|
||||
for restart in (False, True):
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
client = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
client = Client(port, root/'cert.pem')
|
||||
features, identities = client.disco('own-pep', 'alice@localhost')
|
||||
assert ('pubsub', 'pep') in identities and PUBSUB + '#publish-options' in features, (features, identities)
|
||||
if restart:
|
||||
check_items(client)
|
||||
else:
|
||||
sequence(client)
|
||||
finally:
|
||||
if client is not None:
|
||||
client.sock.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
if sys.exc_info()[0] is not None:
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
print('PASS: TLS Monocles/Conversations PEP first-run, no-to publishing, owner config recovery, migrated metadata, device item selectors and restart persistence')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,98 @@
|
||||
use rust_xmpp::pep_diagnostics::{event_fields, item_fields};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn item(xml: &str) -> BTreeMap<&'static str, String> {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
item_fields(doc.root_element()).into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonempty_xml_can_be_an_empty_android_device_list() {
|
||||
let fields = item(
|
||||
"<items xmlns='http://jabber.org/protocol/pubsub'><item><list xmlns='eu.siacs.conversations.axolotl'><device id='2147483648'/><device id='bad-secret-id'/><device/><device id=' 123'/></list></item></items>",
|
||||
);
|
||||
assert_eq!(fields["device_protocol"], "legacy");
|
||||
assert_eq!(fields["valid_device_count"], "0");
|
||||
assert_eq!(fields["invalid_device_count"], "4");
|
||||
assert!(!format!("{fields:?}").contains("bad-secret-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_integer_limits_and_duplicates_match_java_parser() {
|
||||
let fields = item(
|
||||
"<items><item><list xmlns='eu.siacs.conversations.axolotl'><device id='2147483647'/><device id='-2147483648'/><device id='-2147483649'/><device id='+1'/><device id='01'/><device id='١'/><device id='1'/><device id='0'/><device id='-0'/><device id='+'/><device id='1 '/><device id='𝟙'/></list></item></items>",
|
||||
);
|
||||
assert_eq!(fields["valid_device_count"], "4");
|
||||
assert_eq!(fields["invalid_device_count"], "4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payloadless_notification_is_visible_without_identifiers() {
|
||||
let doc = roxmltree::Document::parse("<message from='private@example.test' to='secret@example.test/resource'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='eu.siacs.conversations.axolotl.devicelist'><item id='private-item'/></items></event></message>").unwrap();
|
||||
let fields: BTreeMap<_, _> = event_fields(doc.root_element())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(fields["node_kind"], "omemo-devices");
|
||||
assert_eq!(fields["items_count"], "1");
|
||||
assert_eq!(fields["content_present"], "false");
|
||||
assert_eq!(fields["valid_device_count"], "0");
|
||||
assert_eq!(fields["device_protocol"], "other");
|
||||
let rendered = format!("{fields:?}");
|
||||
for secret in ["private", "secret", "resource", "devicelist"] {
|
||||
assert!(!rendered.contains(secret));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_first_direct_item_is_parsed_and_protocols_are_distinct() {
|
||||
let doc = roxmltree::Document::parse("<message><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='urn:xmpp:omemo:2:devices'><item><devices xmlns='urn:xmpp:omemo:2'><device id='8'/><device id='8'/><wrapper><device id='9'/></wrapper></devices></item><item><devices xmlns='urn:xmpp:omemo:2'><device id='10'/></devices></item></items></event></message>").unwrap();
|
||||
let fields: BTreeMap<_, _> = event_fields(doc.root_element())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(fields["device_protocol"], "omemo2");
|
||||
assert_eq!(fields["items_count"], "2");
|
||||
assert_eq!(fields["content_present"], "true");
|
||||
assert_eq!(fields["valid_device_count"], "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_node_payload_and_wrong_namespace_do_not_leak() {
|
||||
let doc = roxmltree::Document::parse("<message><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='PRIVATE-NODE'><item id='PRIVATE-ID'><list xmlns='PRIVATE-NS'><device id='PRIVATE-DEVICE'>PRIVATE-KEY</device></list></item></items></event></message>").unwrap();
|
||||
let fields: BTreeMap<_, _> = event_fields(doc.root_element())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(fields["node_kind"], "other");
|
||||
assert_eq!(fields["device_protocol"], "other");
|
||||
assert_eq!(fields["content_present"], "true");
|
||||
assert!(!format!("{fields:?}").contains("PRIVATE"));
|
||||
for xml in [
|
||||
"<iq><event xmlns='http://jabber.org/protocol/pubsub#event'><items/></event></iq>",
|
||||
"<message><event xmlns='WRONG'><items/></event></message>",
|
||||
"<message><event xmlns='http://jabber.org/protocol/pubsub#event'><purge/></event></message>",
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
assert!(event_fields(doc.root_element()).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_classification_does_not_inspect_or_expose_avatar_data() {
|
||||
for (node, expected) in [
|
||||
("urn:xmpp:avatar:metadata", "avatar-metadata"),
|
||||
("urn:xmpp:avatar:data", "avatar-data"),
|
||||
] {
|
||||
let xml = format!(
|
||||
"<message><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='{node}'><item><data xmlns='urn:xmpp:avatar:data'>PRIVATE-IMAGE</data></item></items></event></message>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
let fields: BTreeMap<_, _> = event_fields(doc.root_element())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(fields["node_kind"], expected);
|
||||
assert!(!format!("{fields:?}").contains("PRIVATE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use rust_xmpp::pubsub;
|
||||
use std::path::Path;
|
||||
|
||||
fn account(root: &Path) {
|
||||
rust_xmpp::auth::create(root, "example.test", "alice", "secret", "SHA-256").unwrap();
|
||||
}
|
||||
|
||||
fn query(root: &Path, actor: &str, to: &str, namespace: &str, node: Option<&str>) -> String {
|
||||
let node = node.map(|n| format!(" node='{n}'")).unwrap_or_default();
|
||||
let xml =
|
||||
format!("<iq type='get' id='disco' to='{to}'><query xmlns='{namespace}'{node}/></iq>");
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
pubsub::handle(root, "example.test", actor, doc.root_element())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies
|
||||
.join("")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_disco_advertises_pep_without_requiring_a_node() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
for actor in ["alice@example.test/phone", "bob@remote.test/phone"] {
|
||||
let reply = query(
|
||||
d.path(),
|
||||
actor,
|
||||
"alice@example.test",
|
||||
"http://jabber.org/protocol/disco#info",
|
||||
None,
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&reply).unwrap();
|
||||
assert!(doc.descendants().any(|n| n.tag_name().name() == "identity"
|
||||
&& n.attribute("category") == Some("pubsub")
|
||||
&& n.attribute("type") == Some("pep")));
|
||||
assert!(
|
||||
!doc.descendants()
|
||||
.any(|n| n.attribute("category") == Some("server"))
|
||||
);
|
||||
for feature in [
|
||||
"auto-create",
|
||||
"auto-subscribe",
|
||||
"filtered-notifications",
|
||||
"create-and-configure",
|
||||
"publish-options",
|
||||
"persistent-items",
|
||||
] {
|
||||
let feature = format!("http://jabber.org/protocol/pubsub#{feature}");
|
||||
assert!(
|
||||
doc.descendants().any(|n| n.tag_name().name() == "feature"
|
||||
&& n.attribute("var") == Some(feature.as_str())),
|
||||
"missing {feature}"
|
||||
);
|
||||
}
|
||||
for feature in [
|
||||
"http://jabber.org/protocol/disco#info",
|
||||
"http://jabber.org/protocol/disco#items",
|
||||
] {
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.attribute("var") == Some(feature))
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
query(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
"missing@example.test",
|
||||
"http://jabber.org/protocol/disco#info",
|
||||
None
|
||||
)
|
||||
.contains("item-not-found")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_discovery_does_not_intercept_server_or_resource_caps() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
for target in [
|
||||
None,
|
||||
Some("example.test"),
|
||||
Some("alice@example.test/phone"),
|
||||
Some("alice@remote.test"),
|
||||
] {
|
||||
let to = target.map(|t| format!(" to='{t}'")).unwrap_or_default();
|
||||
let xml = format!(
|
||||
"<iq type='get' id='disco'{to}><query xmlns='http://jabber.org/protocol/disco#info'/></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
assert!(
|
||||
pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/phone",
|
||||
doc.root_element()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_discovery_keeps_node_identity_errors_and_access_checks() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path());
|
||||
let create = "<iq type='set' id='create' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><create node='private'/></pubsub></iq>";
|
||||
let create_doc = roxmltree::Document::parse(create).unwrap();
|
||||
let created = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/phone",
|
||||
create_doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!created.replies[0].contains("type='error'"));
|
||||
let xml = "<iq type='set' id='publish' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='private'><item id='one'><data xmlns='urn:test'/></item></publish></pubsub></iq>";
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/phone",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for namespace in [
|
||||
"http://jabber.org/protocol/disco#info",
|
||||
"http://jabber.org/protocol/disco#items",
|
||||
] {
|
||||
let denied = query(
|
||||
d.path(),
|
||||
"bob@remote.test/phone",
|
||||
"alice@example.test",
|
||||
namespace,
|
||||
Some("private"),
|
||||
);
|
||||
assert!(denied.contains("forbidden"));
|
||||
assert!(!denied.contains("name='one'"));
|
||||
assert!(
|
||||
query(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
"alice@example.test",
|
||||
namespace,
|
||||
Some("missing")
|
||||
)
|
||||
.contains("item-not-found")
|
||||
);
|
||||
let listed = query(
|
||||
d.path(),
|
||||
"bob@remote.test/phone",
|
||||
"alice@example.test",
|
||||
namespace,
|
||||
None,
|
||||
);
|
||||
assert!(!listed.contains("node='private'"));
|
||||
}
|
||||
let info = query(
|
||||
d.path(),
|
||||
"alice@example.test/phone",
|
||||
"alice@example.test",
|
||||
"http://jabber.org/protocol/disco#info",
|
||||
Some("private"),
|
||||
);
|
||||
assert!(info.contains("node='private'"));
|
||||
assert!(info.contains("type='leaf'"));
|
||||
assert!(!info.contains("type='pep'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_component_does_not_claim_pep_automatic_subscriptions() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let doc = roxmltree::Document::parse("<iq type='get' id='disco' to='pubsub.example.test'><query xmlns='http://jabber.org/protocol/disco#info'/></iq>").unwrap();
|
||||
let reply = pubsub::handle_component(
|
||||
d.path(),
|
||||
"pubsub.example.test",
|
||||
"alice@example.test/phone",
|
||||
&[],
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies
|
||||
.join("");
|
||||
assert!(reply.contains("type='service'"));
|
||||
assert!(!reply.contains("type='pep'"));
|
||||
for feature in ["auto-create", "auto-subscribe", "filtered-notifications"] {
|
||||
assert!(!reply.contains(&format!("pubsub#{feature}")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Multi-item PEP nodes must expose their newest item first, as native Prosody does.
|
||||
use rust_xmpp::{auth, pubsub};
|
||||
use std::path::Path;
|
||||
|
||||
const DEVICES: &str = "eu.siacs.conversations.axolotl.devicelist";
|
||||
const PUBSUB: &str = "http://jabber.org/protocol/pubsub";
|
||||
|
||||
fn request(root: &Path, typ: &str, body: &str) -> String {
|
||||
let stanza = format!(
|
||||
"<iq type='{typ}' id='order' to='alice@example.org'><pubsub xmlns='{PUBSUB}'>{body}</pubsub></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&stanza).unwrap();
|
||||
let result = pubsub::handle(
|
||||
root,
|
||||
"example.org",
|
||||
"alice@example.org/phone",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let reply = result.replies.into_iter().next().unwrap();
|
||||
assert!(reply.contains("type='result'"), "{reply}");
|
||||
reply
|
||||
}
|
||||
|
||||
fn setup(root: &Path) {
|
||||
auth::create(root, "example.org", "alice", "test-only", "SHA-256").unwrap();
|
||||
request(
|
||||
root,
|
||||
"set",
|
||||
&format!(
|
||||
"<create node='{DEVICES}'/><configure><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#node_config</value></field><field var='pubsub#max_items'><value>4</value></field></x></configure>"
|
||||
),
|
||||
);
|
||||
for (id, devices) in [("old", ""), ("current", "<device id='42'/>")] {
|
||||
request(
|
||||
root,
|
||||
"set",
|
||||
&format!(
|
||||
"<publish node='{DEVICES}'><item id='{id}'><list xmlns='eu.siacs.conversations.axolotl'>{devices}</list></item></publish>"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_pep_item_is_current_device_list_even_with_retained_old_items() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
setup(d.path());
|
||||
for limit in ["", " max_items='2'", " max_items='1'"] {
|
||||
let reply = request(
|
||||
d.path(),
|
||||
"get",
|
||||
&format!("<items node='{DEVICES}'{limit}/>"),
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&reply).unwrap();
|
||||
// Conversations-family parsers read pubsub/items/item's first child.
|
||||
// A descendant-only assertion would also accept an obsolete first item.
|
||||
let items = doc
|
||||
.root_element()
|
||||
.children()
|
||||
.find(|n| n.has_tag_name((PUBSUB, "pubsub")))
|
||||
.unwrap()
|
||||
.children()
|
||||
.find(|n| n.has_tag_name((PUBSUB, "items")))
|
||||
.unwrap();
|
||||
let first = items.first_element_child().unwrap();
|
||||
assert_eq!(first.attribute("id"), Some("current"), "{reply}");
|
||||
let list = first.first_element_child().unwrap();
|
||||
assert!(list.has_tag_name(("eu.siacs.conversations.axolotl", "list")));
|
||||
assert_eq!(
|
||||
list.first_element_child().unwrap().attribute("id"),
|
||||
Some("42")
|
||||
);
|
||||
let ids: Vec<_> = items
|
||||
.children()
|
||||
.filter(|n| n.is_element())
|
||||
.map(|n| n.attribute("id").unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
if limit.contains("'1'") {
|
||||
vec!["current"]
|
||||
} else {
|
||||
vec!["current", "old"]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn native_prosody_orders_retained_pep_items_newest_first() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
setup(d.path());
|
||||
let bootstrap = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/prosody_compat/oracle.lua"
|
||||
))
|
||||
.unwrap();
|
||||
let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap();
|
||||
let script = format!(
|
||||
"{bootstrap}\n{}",
|
||||
r#"
|
||||
assert(mm.unload('example.org','auth_internal_hashed'));
|
||||
require('prosody.core.usermanager').initialize_host('example.org');
|
||||
assert(mm.load('example.org','pep'));
|
||||
local service = mm.get_module('example.org','pep').get_pep_service('alice');
|
||||
local ok, items = service:get_items('eu.siacs.conversations.axolotl.devicelist','alice@example.org');
|
||||
assert(ok, items);
|
||||
assert(items[1] == 'current', tostring(items[1]));
|
||||
assert(items[2] == 'old', tostring(items[2]));
|
||||
assert(items.current.tags[1].tags[1].attr.id == '42');
|
||||
print('NATIVE_PEP_NEWEST_FIRST_OK');
|
||||
"#
|
||||
);
|
||||
let path = d.path().join("native-order.lua");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
let output = std::process::Command::new(
|
||||
std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into()),
|
||||
)
|
||||
.arg(path)
|
||||
.arg(std::env::var("PROSODY_REFERENCE_SOURCE").unwrap())
|
||||
.arg(d.path())
|
||||
.arg("pubsub")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real STARTTLS regression for private PEP notification diagnostics.
|
||||
|
||||
Usage: python3 tests/pep_notifications_network.py target/release/rust-xmpp
|
||||
Exercises generated notifications, payload suppression and matching IQ results.
|
||||
The fixtures demonstrate diagnostic coverage, not a production root cause.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from _wire_client import Client
|
||||
|
||||
PUBSUB = 'http://jabber.org/protocol/pubsub'
|
||||
EVENT = PUBSUB + '#event'
|
||||
OWNER = PUBSUB + '#owner'
|
||||
LEGACY = 'eu.siacs.conversations.axolotl'
|
||||
OMEMO2 = 'urn:xmpp:omemo:2'
|
||||
PRIVATE_ITEM = 'private-diagnostic-item-607b'
|
||||
PRIVATE_KEY = 'PRIVATE-KEY-MATERIAL-952a'
|
||||
VALID_ID = '1937264819'
|
||||
BAD_ID = 'private-invalid-id-65d2'
|
||||
|
||||
|
||||
def require(condition, reason):
|
||||
if not condition:
|
||||
raise AssertionError(reason)
|
||||
|
||||
|
||||
def fields(record):
|
||||
return {f['key']: f['value'] for f in record['fields']}
|
||||
|
||||
|
||||
def records(path):
|
||||
return [json.loads(line) for line in path.read_text().splitlines()]
|
||||
|
||||
|
||||
def wait_event(path, start, expected):
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
events = [r for r in records(path)[start:]
|
||||
if r.get('event') == 'notification-generated']
|
||||
for event in events:
|
||||
actual = fields(event)
|
||||
if all(actual.get(k) == v for k, v in expected.items()):
|
||||
require(event.get('module') == 'pep', 'wrong notification log module')
|
||||
require(event.get('connection') is None, 'notification log acquired an account context')
|
||||
return
|
||||
time.sleep(.02)
|
||||
raise AssertionError('required private notification diagnostic was not generated')
|
||||
|
||||
|
||||
def iq(client, identifier, payload, typ='set'):
|
||||
client.send(f"<iq type='{typ}' id='{identifier}'>{payload}</iq>")
|
||||
return client.iq(identifier)
|
||||
|
||||
|
||||
def publish(client, node, payload, identifier):
|
||||
return iq(client, identifier, f"<pubsub xmlns='{PUBSUB}'><publish node='{node}'><item id='{PRIVATE_ITEM}'>{payload}</item></publish></pubsub>")
|
||||
|
||||
|
||||
def notification(client, node):
|
||||
# Client.take preserves unrelated IQs/messages and handles either wire order.
|
||||
message = client.take(lambda e: e.tag == '{jabber:client}message'
|
||||
and e.find(f'{{{EVENT}}}event/{{{EVENT}}}items') is not None
|
||||
and e.find(f'{{{EVENT}}}event/{{{EVENT}}}items').get('node') == node)
|
||||
require(message.get('from') == 'alice@localhost', 'notification sender must be account bare JID')
|
||||
wrapper = message.find(f'{{{EVENT}}}event/{{{EVENT}}}items')
|
||||
items = wrapper.findall(f'{{{EVENT}}}item')
|
||||
require(len(items) == 1, 'expected exactly one wire notification item')
|
||||
return items[0]
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-pep-notifications-') as tmp:
|
||||
root = Path(tmp)
|
||||
cert = root / 'cert.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
||||
'-days', '1', '-keyout', str(root / 'key.pem'), '-out', str(cert),
|
||||
'-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
log_path = root / 'structured.jsonl'
|
||||
config = root / 'config.json'
|
||||
config.write_text(json.dumps({'global': {
|
||||
'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
|
||||
'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [],
|
||||
'modules_enabled': ['tls', 'saslauth', 'pep'],
|
||||
'log_format': 'json', 'log': {'debug': str(log_path)}},
|
||||
'hosts': {'localhost': {'options': {
|
||||
'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
||||
'ssl': {'key': str(root / 'key.pem'), 'certificate': str(cert)}}}}}))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
|
||||
input='secret\n', text=True, check=True, capture_output=True)
|
||||
with (root / 'process.log').open('w') as output:
|
||||
process = subprocess.Popen([binary, 'serve', str(config), '--experimental'],
|
||||
stdout=output, stderr=output)
|
||||
client = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
require(process.poll() is None, 'fixture server stopped')
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('fixture server did not listen')
|
||||
client = Client(port, cert, 'alice', 'secret', resource='notification-test')
|
||||
for index, (node, namespace, element, protocol) in enumerate([
|
||||
(LEGACY + '.devicelist', LEGACY, 'list', 'legacy'),
|
||||
(OMEMO2 + ':devices', OMEMO2, 'devices', 'omemo2')]):
|
||||
payload = f"<{element} xmlns='{namespace}'><device id='{VALID_ID}'/><device id='{VALID_ID}'/><device id='{BAD_ID}'/><key>{PRIVATE_KEY}</key></{element}>"
|
||||
publish(client, node, payload, f'initial-{index}')
|
||||
start = len(records(log_path))
|
||||
iq(client, f'subscribe-{index}', f"<pubsub xmlns='{PUBSUB}'><subscribe node='{node}' jid='{client.jid}'/></pubsub>")
|
||||
item = notification(client, node)
|
||||
require(item.find(f'{{{namespace}}}{element}') is not None,
|
||||
'replayed wire notification lost the published device payload')
|
||||
wait_event(log_path, start, {'node_kind': 'omemo-devices', 'device_protocol': protocol,
|
||||
'valid_device_count': '1', 'invalid_device_count': '1',
|
||||
'content_present': 'true', 'items_count': '1'})
|
||||
|
||||
# Successful IQ retrieval and payloadless notifications can coexist.
|
||||
form = "<x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#node_config</value></field><field var='pubsub#deliver_payloads'><value>false</value></field></x>"
|
||||
iq(client, f'configure-{index}', f"<pubsub xmlns='{OWNER}'><configure node='{node}'>{form}</configure></pubsub>")
|
||||
start = len(records(log_path))
|
||||
publish(client, node, payload, f'payloadless-{index}')
|
||||
require(len(notification(client, node)) == 0,
|
||||
'deliver_payloads=false did not suppress wire payload')
|
||||
wait_event(log_path, start, {'node_kind': 'omemo-devices', 'device_protocol': 'other',
|
||||
'valid_device_count': '0', 'invalid_device_count': '0',
|
||||
'content_present': 'false', 'items_count': '1'})
|
||||
response = iq(client, f'fetch-{index}', f"<pubsub xmlns='{PUBSUB}'><items node='{node}'/></pubsub>", typ='get')
|
||||
require(response.find(f'{{{PUBSUB}}}pubsub/{{{PUBSUB}}}items/{{{PUBSUB}}}item/{{{namespace}}}{element}') is not None,
|
||||
'payload suppression unexpectedly removed stored IQ payload')
|
||||
|
||||
diagnostics = [r for r in records(log_path)
|
||||
if r.get('event') in ('pubsub-iq-result', 'notification-generated')]
|
||||
serialized = json.dumps(diagnostics)
|
||||
for secret in (PRIVATE_ITEM, PRIVATE_KEY, VALID_ID, BAD_ID):
|
||||
require(secret not in serialized, 'private fixture value leaked into PEP diagnostics')
|
||||
iq_rows = [fields(r) for r in diagnostics if r['event'] == 'pubsub-iq-result']
|
||||
for protocol in ('legacy', 'omemo2'):
|
||||
require(any(f.get('action') == 'items' and f.get('device_protocol') == protocol
|
||||
and f.get('valid_device_count') == '1'
|
||||
and f.get('invalid_device_count') == '1' for f in iq_rows),
|
||||
'successful IQ device parser diagnostic missing')
|
||||
finally:
|
||||
if client is not None:
|
||||
client.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
print('PASS: STARTTLS PEP replay/live diagnostics, legacy/OMEMO2, payloadless notifications with intact IQ payloads, and private values excluded')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Legacy Android first-item PEP parsing over real loopback STARTTLS.
|
||||
|
||||
Usage: python3 tests/pep_order_network.py target/debug/rust-xmpp
|
||||
Monocles IqGenerator.retrieveDeviceIds requests every item without max_items;
|
||||
IqParser.getItem returns only the first item, then deviceIds reads its list.
|
||||
An older empty record must not hide the latest populated device list.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from push_discovery import Client
|
||||
from pep_client_network import LEGACY, PUBSUB, fetch, publish, result
|
||||
|
||||
NODE = LEGACY + '.devicelist'
|
||||
DEVICE_IDS = ('2073401123', '2073401456', '2073401789')
|
||||
|
||||
|
||||
def check_android_fetch(client, expected):
|
||||
response = result(fetch(client, NODE, identifier='own-device-list'))
|
||||
# Match IqParser.getItem: do not search all descendants or merge item lists.
|
||||
items = response.find(f'{{{PUBSUB}}}pubsub/{{{PUBSUB}}}items')
|
||||
assert items is not None, ET.tostring(response)
|
||||
first = items.find(f'{{{PUBSUB}}}item')
|
||||
assert first is not None and first.get('id') == 'current', ET.tostring(response)
|
||||
device_list = first.find(f'{{{LEGACY}}}list')
|
||||
assert device_list is not None, ET.tostring(response)
|
||||
device_ids = {device.get('id') for device in device_list
|
||||
if device.tag == f'{{{LEGACY}}}device'}
|
||||
assert device_ids == expected, ET.tostring(response)
|
||||
# Retain the old record to exercise real multi-item ordering, not truncation.
|
||||
assert [item.get('id') for item in items] == ['current', 'older'], ET.tostring(response)
|
||||
|
||||
|
||||
def check_info_log(path, expected_reads):
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
text = path.read_text() if path.exists() else ''
|
||||
records = [json.loads(line) for line in text.splitlines()]
|
||||
reads = []
|
||||
for record in records:
|
||||
if record.get('event') != 'pubsub-iq-result':
|
||||
continue
|
||||
fields = {field['key']: field['value'] for field in record['fields']}
|
||||
if fields.get('action') == 'items':
|
||||
reads.append((record, fields))
|
||||
if len(reads) >= expected_reads:
|
||||
break
|
||||
assert time.monotonic() < deadline, 'missing PEP info log records'
|
||||
time.sleep(.02)
|
||||
assert len(reads) == expected_reads, 'unexpected PEP retrieval count'
|
||||
for record, fields in reads:
|
||||
assert record['level'] == 'info', record
|
||||
for key, value in {'status': 'result', 'items_count': '2',
|
||||
'first_content_kind': 'omemo-devices',
|
||||
'first_device_count': '2',
|
||||
'target_scope': 'self-account'}.items():
|
||||
assert fields.get(key) == value, (key, fields)
|
||||
assert all(device not in text for device in DEVICE_IDS), 'device ID leaked in info logs'
|
||||
assert LEGACY not in text, 'raw PEP node leaked in info logs'
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-pep-order-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps({'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'pep'], 'log_format': 'json', 'log': {'info': str(root/'structured.jsonl')}}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
|
||||
for restart in (False, True):
|
||||
info_log = root/'structured.jsonl'
|
||||
if info_log.exists():
|
||||
info_log.unlink()
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
client = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
client = Client(port, root/'cert.pem')
|
||||
features, identities = client.disco('own-pep', 'alice@localhost')
|
||||
assert ('pubsub', 'pep') in identities and PUBSUB + '#persistent-items' in features
|
||||
if not restart:
|
||||
# Existing device-list nodes can retain multiple differently named
|
||||
# records. The older item is intentionally a valid empty list.
|
||||
result(publish(client, NODE, 'older', f"<list xmlns='{LEGACY}'/>", {'pubsub#max_items': '3'}))
|
||||
result(publish(client, NODE, 'current', f"<list xmlns='{LEGACY}'><device id='2073401123'/><device id='2073401456'/></list>"))
|
||||
check_android_fetch(client, set(DEVICE_IDS[:2]))
|
||||
# Monocles publishes id=current, then reads its own node without
|
||||
# an item selector. Replacement must remain the first record.
|
||||
result(publish(client, NODE, 'current', f"<list xmlns='{LEGACY}'><device id='2073401123'/><device id='2073401789'/></list>"))
|
||||
check_android_fetch(client, {DEVICE_IDS[0], DEVICE_IDS[2]})
|
||||
check_info_log(info_log, 1 if restart else 2)
|
||||
finally:
|
||||
if client is not None:
|
||||
client.sock.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
if sys.exc_info()[0] is not None:
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
print('PASS: Android first-item device-list parsing sees latest publish and replacement before and after restart')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,368 @@
|
||||
use roxmltree::Document;
|
||||
use rust_xmpp::{
|
||||
account_modules, preauth,
|
||||
storage::{self, LuaValue as V},
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
fn account(root: &std::path::Path, user: &str) {
|
||||
let mut data = V::table();
|
||||
data.set("password", "test".into());
|
||||
storage::save(root, "example.org", Some(user), "accounts", &data).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn invitations_expire_are_single_use_and_owner_scoped() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
account(d.path(), "other");
|
||||
let invite =
|
||||
preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 10, 100)
|
||||
.unwrap();
|
||||
assert_eq!(invite.token.len(), 32);
|
||||
assert!(
|
||||
!preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"other@example.org",
|
||||
"bob@remote.test",
|
||||
&invite.token,
|
||||
105
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test/mobile",
|
||||
&invite.token,
|
||||
109
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
&invite.token,
|
||||
109
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let expired =
|
||||
preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 10, 100)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
&expired.token,
|
||||
110
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn identity_scope_does_not_burn_on_wrong_sender_or_leak_to_prosody() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let invite = preauth::create_invite(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
Some("Bob@remote.test"),
|
||||
10,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
storage::load(d.path(), "example.org", Some("alice"), "invite_token")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
!preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"eve@remote.test",
|
||||
&invite.token,
|
||||
101
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
&invite.token,
|
||||
101
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn prosody_numeric_expiry_is_consumed_and_other_entries_preserved() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let mut tokens = V::table();
|
||||
tokens.set("legacy", V::Number(200.0));
|
||||
tokens.set("unrelated", V::Integer(300));
|
||||
storage::save(
|
||||
d.path(),
|
||||
"example.org",
|
||||
Some("alice"),
|
||||
"invite_token",
|
||||
&tokens,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
"legacy",
|
||||
100
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let data = storage::load(d.path(), "example.org", Some("alice"), "invite_token")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(data.get("legacy").is_none());
|
||||
assert_eq!(data.get("unrelated"), Some(&V::Integer(300)));
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_or_wrong_namespace_tokens_do_not_qualify() {
|
||||
for xml in [
|
||||
"<presence type='subscribe'><preauth xmlns='wrong' token='x'/></presence>",
|
||||
"<presence type='subscribed'><preauth xmlns='urn:xmpp:pars:0' token='x'/></presence>",
|
||||
"<presence type='subscribe'><preauth xmlns='urn:xmpp:pars:0' token='x'/><preauth xmlns='urn:xmpp:pars:0' token='y'/></presence>",
|
||||
] {
|
||||
let doc = Document::parse(xml).unwrap();
|
||||
assert!(preauth::token_from_presence(doc.root_element()).is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn standard_adhoc_command_returns_contact_uri() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let doc = Document::parse("<iq type='set' id='i' to='example.org'><command xmlns='http://jabber.org/protocol/commands' node='urn:xmpp:invite#invite' action='execute'/></iq>").unwrap();
|
||||
let result = preauth::handle_iq(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org/phone",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let answer = Document::parse(&result.replies[0]).unwrap();
|
||||
assert_eq!(answer.root_element().attribute("type"), Some("result"));
|
||||
assert!(result.replies[0].contains("xmpp:alice@example.org?roster;preauth="));
|
||||
assert!(result.replies[0].contains("var='expire'"));
|
||||
assert!(!result.replies[0].contains("ibr=y"));
|
||||
}
|
||||
#[test]
|
||||
fn unknown_client_token_is_preserved_on_remote_and_pending_delivery() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let doc = Document::parse("<presence xmlns='jabber:client' to='bob@remote.test' type='subscribe'><preauth xmlns='urn:xmpp:pars:0' token='client-token'/></presence>").unwrap();
|
||||
let result = account_modules::subscription(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org/phone",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let forwarded = result
|
||||
.deliveries
|
||||
.iter()
|
||||
.find(|(jid, _)| jid == "bob@remote.test")
|
||||
.unwrap();
|
||||
assert!(forwarded.1.contains("client-token"));
|
||||
let doc = Document::parse("<presence xmlns='jabber:client' to='alice@example.org' type='subscribe'><preauth xmlns='urn:xmpp:pars:0' token='unknown-token'/></presence>").unwrap();
|
||||
let result = account_modules::inbound_subscription(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"bob@remote.test",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|(jid, xml)| jid == "alice@example.org" && xml.contains("unknown-token"))
|
||||
);
|
||||
let pending = account_modules::pending_subscriptions(d.path(), "example.org", "alice").unwrap();
|
||||
assert!(pending.iter().any(|xml| xml.contains("unknown-token")));
|
||||
}
|
||||
#[test]
|
||||
fn valid_token_autoapproves_and_sends_reciprocal_request() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let invite = preauth::create_invite(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
None,
|
||||
60,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let xml = format!(
|
||||
"<presence xmlns='jabber:client' to='alice@example.org' type='subscribe'><preauth xmlns='urn:xmpp:pars:0' token='{}'/></presence>",
|
||||
invite.token
|
||||
);
|
||||
let doc = Document::parse(&xml).unwrap();
|
||||
let result = account_modules::inbound_subscription(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"bob@remote.test",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for kind in ["subscribed", "subscribe"] {
|
||||
assert!(result.deliveries.iter().any(|(jid, xml)| {
|
||||
jid == "bob@remote.test"
|
||||
&& Document::parse(xml)
|
||||
.unwrap()
|
||||
.root_element()
|
||||
.attribute("type")
|
||||
== Some(kind)
|
||||
}));
|
||||
}
|
||||
let roster = storage::load(d.path(), "example.org", Some("alice"), "roster")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let entry = roster.get("bob@remote.test").unwrap();
|
||||
assert_eq!(entry.get("subscription").and_then(V::as_str), Some("from"));
|
||||
assert_eq!(entry.get("ask").and_then(V::as_str), Some("subscribe"));
|
||||
assert!(
|
||||
account_modules::pending_subscriptions(d.path(), "example.org", "alice")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn concurrent_redemption_has_exactly_one_winner() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let invite =
|
||||
preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 60, 100)
|
||||
.unwrap();
|
||||
let winners = std::thread::scope(|scope| {
|
||||
let tasks: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let token = &invite.token;
|
||||
let root = d.path();
|
||||
scope.spawn(move || {
|
||||
preauth::consume(
|
||||
root,
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
token,
|
||||
101,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
tasks
|
||||
.into_iter()
|
||||
.map(|t| t.join().unwrap())
|
||||
.filter(|accepted| *accepted)
|
||||
.count()
|
||||
});
|
||||
assert_eq!(winners, 1);
|
||||
}
|
||||
#[test]
|
||||
fn local_invitation_updates_both_rosters_without_granting_reverse_subscription() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
account(d.path(), "bob");
|
||||
let invite = preauth::create_invite(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
None,
|
||||
60,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let xml = format!(
|
||||
"<presence xmlns='jabber:client' to='alice@example.org' type='subscribe'><preauth xmlns='urn:xmpp:pars:0' token='{}'/></presence>",
|
||||
invite.token
|
||||
);
|
||||
let doc = Document::parse(&xml).unwrap();
|
||||
let result = account_modules::subscription(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"bob@example.org/mobile",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(result.deliveries.iter().any(|(jid, xml)| {
|
||||
jid == "bob@example.org"
|
||||
&& Document::parse(xml)
|
||||
.unwrap()
|
||||
.root_element()
|
||||
.attribute("type")
|
||||
== Some("subscribe")
|
||||
}));
|
||||
for (user, contact, subscription) in [
|
||||
("alice", "bob@example.org", "from"),
|
||||
("bob", "alice@example.org", "to"),
|
||||
] {
|
||||
let data = storage::load(d.path(), "example.org", Some(user), "roster")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
data.get(contact)
|
||||
.unwrap()
|
||||
.get("subscription")
|
||||
.and_then(V::as_str),
|
||||
Some(subscription)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tokens_do_not_create_invite_stores_and_disabled_accounts_cannot_issue() {
|
||||
let d = tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
assert!(
|
||||
!preauth::consume(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
"unknown",
|
||||
100
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!storage::path(d.path(), "example.org", Some("alice"), "invite_token").exists());
|
||||
assert!(!storage::path(d.path(), "example.org", Some("alice"), "preauth_scoped").exists());
|
||||
storage::update(d.path(), "example.org", Some("alice"), "accounts", |v| {
|
||||
v.set("disabled", V::Bool(true));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
preauth::create_invite(d.path(), "example.org", "alice@example.org", None, 10, 100)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Interoperability with the supplied, unmodified Prosody mod_invites.
|
||||
use rust_xmpp::{auth, preauth};
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn native_prosody_invitation_tokens_roundtrip() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
auth::create(data.path(), "example.org", "alice", "secret", "SHA-256").unwrap();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let rust = preauth::create_invite(
|
||||
data.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
None,
|
||||
120,
|
||||
now,
|
||||
)
|
||||
.unwrap();
|
||||
let scoped = preauth::create_invite(
|
||||
data.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
Some("bob@remote.test"),
|
||||
120,
|
||||
now,
|
||||
)
|
||||
.unwrap();
|
||||
let bootstrap = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/prosody_compat/oracle.lua"
|
||||
))
|
||||
.unwrap();
|
||||
let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap();
|
||||
let script = format!(
|
||||
"{bootstrap}\n{}",
|
||||
r#"
|
||||
config.set('*', 'modules_enabled', {});
|
||||
prosody.shutdown = function() end; -- mod_invites' real-server storage bootstrap condition.
|
||||
assert(mm.load('example.org', 'invites'));
|
||||
local invites = assert(mm.get_module('example.org', 'invites'));
|
||||
if phase == 'invites' then
|
||||
local invite = assert(invites.get(arg[5], 'alice'));
|
||||
assert(invite.username == 'alice');
|
||||
assert(not invites.get(arg[5], 'other'));
|
||||
assert(not invites.get(arg[6], 'alice')); -- Scoped Rust token cannot be redeemed by unaware native code.
|
||||
assert(invite:use());
|
||||
assert(not invites.get(arg[5], 'alice'));
|
||||
local native = assert(invites.create_contact('alice', false, nil, 120));
|
||||
assert(not native.allow_registration);
|
||||
print('NATIVE_INVITE_TOKEN '..native.token);
|
||||
else
|
||||
assert(not invites.get(arg[5], 'alice')); -- Rust consumed the native token.
|
||||
print('NATIVE_INVITE_FINAL_OK');
|
||||
end
|
||||
"#
|
||||
);
|
||||
let path = data.path().join("native-invites.lua");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
let source = std::env::var("PROSODY_REFERENCE_SOURCE").unwrap();
|
||||
let lua = std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into());
|
||||
let run = |phase: &str, token: &str| {
|
||||
let result = std::process::Command::new(&lua)
|
||||
.arg(&path)
|
||||
.arg(&source)
|
||||
.arg(data.path())
|
||||
.arg(phase)
|
||||
.arg("SHA-256")
|
||||
.arg(token)
|
||||
.arg(&scoped.token)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
String::from_utf8(result.stdout).unwrap()
|
||||
};
|
||||
let result = run("invites", &rust.token);
|
||||
let token = result
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("NATIVE_INVITE_TOKEN "))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!preauth::consume(
|
||||
data.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
&rust.token,
|
||||
chrono::Utc::now().timestamp()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
preauth::consume(
|
||||
data.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
token,
|
||||
chrono::Utc::now().timestamp()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(run("verify-invites", token).contains("NATIVE_INVITE_FINAL_OK"));
|
||||
assert!(
|
||||
preauth::consume(
|
||||
data.path(),
|
||||
"example.org",
|
||||
"alice@example.org",
|
||||
"bob@remote.test",
|
||||
&scoped.token,
|
||||
chrono::Utc::now().timestamp()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated TLS contact invitation, token forwarding and roster regression."""
|
||||
import json, socket, subprocess, tempfile, time, sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
from _wire_client import Client, local, BIN
|
||||
|
||||
COMMANDS='http://jabber.org/protocol/commands'
|
||||
DISCO='http://jabber.org/protocol/disco#items'
|
||||
PARS='urn:xmpp:pars:0'
|
||||
|
||||
def roster(client, identifier):
|
||||
client.send(f"<iq type='get' id='{identifier}'><query xmlns='jabber:iq:roster'/></iq>")
|
||||
return {e.get('jid'):e.get('subscription') for e in client.iq(identifier).iter() if e.tag=='{jabber:iq:roster}item'}
|
||||
|
||||
def run():
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-preauth-') as tmp:
|
||||
root=Path(tmp)
|
||||
subprocess.run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',str(root/'key.pem'),'-out',str(root/'cert.pem'),'-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1',0));port=probe.getsockname()[1]
|
||||
cfg={'global':{'data_path':str(root/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[port],'http_ports':[],'https_ports':[],'s2s_ports':[],'modules_enabled':['tls','saslauth','roster','disco','pep','cloud_notify','mam','ping','smacks']},'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-256','ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}}}}}
|
||||
config=root/'config.json';config.write_text(json.dumps(cfg))
|
||||
for user in ['alice','bob','carol']:
|
||||
subprocess.run([str(BIN),'create-user',str(config),user,'--password-stdin'],input=user+'-secret\n',text=True,check=True,capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server=subprocess.Popen([str(BIN),'serve',str(config),'--experimental'],stdout=log,stderr=log);clients=[]
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None,'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1',port),timeout=.1):break
|
||||
except OSError:time.sleep(.05)
|
||||
a,b,c=[Client(port,root/'cert.pem',u,u+'-secret') for u in ['alice','bob','carol']];clients=[a,b,c]
|
||||
for client in clients:
|
||||
features=ET.fromstring(client.post_auth_features[client.post_auth_features.index(b'<stream:features'):].replace(b'<stream:features',b"<stream:features xmlns:stream='http://etherx.jabber.org/streams'",1))
|
||||
assert features.find('{urn:xmpp:features:pre-approval}sub') is not None
|
||||
client.available();roster(client,'initial-roster')
|
||||
a.send(f"<iq type='get' id='commands' to='localhost'><query xmlns='{DISCO}' node='{COMMANDS}'/></iq>")
|
||||
assert any(n.get('node')=='urn:xmpp:invite#invite' for n in a.iq('commands').iter())
|
||||
a.send(f"<iq type='set' id='invite' to='localhost'><command xmlns='{COMMANDS}' node='urn:xmpp:invite#invite' action='execute'/></iq>")
|
||||
result=a.iq('invite');fields={n.get('var'):n.find('{jabber:x:data}value').text for n in result.iter('{jabber:x:data}field')}
|
||||
uri=fields['uri'];assert uri.startswith('xmpp:alice@localhost?roster;preauth=') and 'ibr=y' not in uri
|
||||
token=uri.split('preauth=',1)[1];assert len(token)==32 and fields['expire']
|
||||
b.send("<presence to='alice@localhost' type='subscribed'/>");b.sync()
|
||||
b.send(f"<presence to='alice@localhost' type='subscribe'><preauth xmlns='{PARS}' token='{token}'/></presence>");b.sync();a.sync()
|
||||
assert roster(a,'alice-after').get('bob@localhost')=='both'
|
||||
assert roster(b,'bob-after').get('alice@localhost')=='both'
|
||||
c.send(f"<presence to='alice@localhost' type='subscribe' id='replay'><preauth xmlns='{PARS}' token='{token}'/></presence>");c.sync();a.sync()
|
||||
assert roster(a,'replay-denied').get('carol@localhost')=='none'
|
||||
replay=a.take(lambda n:local(n)=='presence' and n.get('id')=='replay')
|
||||
assert replay.find('{'+PARS+'}preauth').get('token')==token
|
||||
# Unknown client-generated tokens must reach the owner even after offline replay.
|
||||
a.close();clients.remove(a);time.sleep(.1)
|
||||
c.send(f"<presence to='alice@localhost' type='subscribe' id='manual'><preauth xmlns='{PARS}' token='client-generated-token'/></presence>");c.sync()
|
||||
a=Client(port,root/'cert.pem','alice','alice-secret','again');clients.append(a);a.available()
|
||||
pending=a.take(lambda n:local(n)=='presence' and n.get('id')=='manual')
|
||||
assert pending.find('{'+PARS+'}preauth').get('token')=='client-generated-token'
|
||||
# A different authenticated account cannot issue invitations in Alice's name.
|
||||
c.send(f"<iq type='set' id='foreign' to='alice@localhost'><command xmlns='{COMMANDS}' node='urn:xmpp:invite#invite'/></iq>");c.iq('foreign',error=True)
|
||||
print('PASS: TLS invitation command discovery, preapproval, single-use mutual roster, replay denial, unknown preauth forwarding/offline pending, account isolation')
|
||||
except BaseException:
|
||||
log.flush();log.seek(0);print(log.read());raise
|
||||
finally:
|
||||
for client in clients:client.close()
|
||||
server.terminate()
|
||||
try:server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:server.kill();server.wait()
|
||||
|
||||
if __name__=='__main__':
|
||||
if len(sys.argv)>1:BIN=Path(sys.argv[1]).resolve()
|
||||
run()
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Exercises the actual supplied mod_pep/util.pubsub, not a duplicate storage decoder.
|
||||
use rust_xmpp::{auth, pubsub};
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn native_prosody_pep_reads_rust_and_rust_reads_native() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
auth::create(d.path(), "example.org", "alice", "test-only", "SHA-256").unwrap();
|
||||
let xml = "<iq type='set'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='urn:test:devices'><item id='rust'><devices xmlns='urn:test:devices'><device id='42'/></devices></item></publish></pubsub></iq>";
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(result.replies[0].contains("type='result'"));
|
||||
let bootstrap = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/prosody_compat/oracle.lua"
|
||||
))
|
||||
.unwrap();
|
||||
let bootstrap = bootstrap.split("if phase == 'probe' then").next().unwrap();
|
||||
let script = format!(
|
||||
"{bootstrap}\n{}",
|
||||
r#"
|
||||
assert(mm.unload('example.org','auth_internal_hashed'));
|
||||
require('prosody.core.usermanager').initialize_host('example.org');
|
||||
assert(require('prosody.core.usermanager').user_exists('alice','example.org'));
|
||||
assert(mm.load('example.org','pep'));
|
||||
local env=assert(mm.get_module('example.org','pep'));
|
||||
local service=env.get_pep_service('alice');
|
||||
local ok,items=service:get_items('urn:test:devices','alice@example.org');assert(ok,items);
|
||||
assert(items[1]=='rust');local item=assert(items['rust']);assert(item.tags[1].attr.xmlns=='urn:test:devices');assert(item.tags[1].tags[1].attr.id=='42');
|
||||
local published=st.stanza('item',{xmlns='http://jabber.org/protocol/pubsub',id='native',publisher='alice@example.org'}):tag('devices',{xmlns='urn:test:devices'}):tag('device',{id='99'}):up():up();
|
||||
local ok,err=service:publish('urn:test:devices','alice@example.org','native',published);assert(ok,err);
|
||||
print('NATIVE_PEP_ROUNDTRIP_OK');
|
||||
"#
|
||||
);
|
||||
let path = d.path().join("native.lua");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
let source = std::env::var("PROSODY_REFERENCE_SOURCE").unwrap();
|
||||
let lua = std::env::var("PROSODY_REFERENCE_LUA").unwrap_or("lua5.4".into());
|
||||
let out = std::process::Command::new(lua)
|
||||
.arg(path)
|
||||
.arg(source)
|
||||
.arg(d.path())
|
||||
.arg("pubsub")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let get = "<iq type='get'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='urn:test:devices'/></pubsub></iq>";
|
||||
let doc = roxmltree::Document::parse(get).unwrap();
|
||||
let out = pubsub::handle(
|
||||
d.path(),
|
||||
"example.org",
|
||||
"alice@example.org/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(out.replies[0].contains("id='native'"), "{:?}", out.replies);
|
||||
assert!(out.replies[0].contains("id='99'"));
|
||||
assert!(!out.replies[0].contains("id='rust'"));
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
use rust_xmpp::{
|
||||
pubsub,
|
||||
storage::{self, LuaValue as V},
|
||||
};
|
||||
use std::path::Path;
|
||||
fn account(root: &Path, user: &str) {
|
||||
let mut data = V::table();
|
||||
data.set("password", "test-only".into());
|
||||
storage::save(root, "example.test", Some(user), "accounts", &data).unwrap();
|
||||
}
|
||||
fn request(root: &Path, actor: &str, body: &str) -> String {
|
||||
let doc = roxmltree::Document::parse(body).unwrap();
|
||||
pubsub::handle(root, "example.test", actor, doc.root_element())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies
|
||||
.join("")
|
||||
}
|
||||
fn iq(typ: &str, to: &str, content: &str) -> String {
|
||||
format!(
|
||||
"<iq type='{typ}' id='x' to='{to}'><pubsub xmlns='http://jabber.org/protocol/pubsub'>{content}</pubsub></iq>"
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn pep_persists_namespaces_and_enforces_owner() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
account(d.path(), "bob");
|
||||
let publish = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='urn:xmpp:omemo:2:devices'><item id='current'><devices xmlns='urn:xmpp:omemo:2' xmlns:ex='urn:extension'><device id='123' ex:label='phone'/></devices></item></publish>",
|
||||
);
|
||||
assert!(request(d.path(), "bob@example.test/r", &publish).contains("forbidden"));
|
||||
assert!(request(d.path(), "alice@example.test/r", &publish).contains("type='result'"));
|
||||
let get = iq(
|
||||
"get",
|
||||
"alice@example.test",
|
||||
"<items node='urn:xmpp:omemo:2:devices'/>",
|
||||
);
|
||||
assert!(request(d.path(), "bob@example.test/r", &get).contains("forbidden"));
|
||||
let response = request(d.path(), "alice@example.test/r", &get);
|
||||
let doc = roxmltree::Document::parse(&response).unwrap();
|
||||
let device = doc
|
||||
.descendants()
|
||||
.find(|n| n.has_tag_name(("urn:xmpp:omemo:2", "device")))
|
||||
.unwrap();
|
||||
assert_eq!(device.attribute(("urn:extension", "label")), Some("phone"));
|
||||
let nodes = storage::load(d.path(), "example.test", Some("alice"), "pep")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(nodes.get("urn:xmpp:omemo:2:devices").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn open_model_and_notifications() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
account(d.path(), "bob");
|
||||
let create = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<create node='test'/><configure><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#node_config</value></field><field var='pubsub#access_model'><value>open</value></field></x></configure>",
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'"));
|
||||
let sub = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<subscribe node='test' jid='bob@example.test/r'/>",
|
||||
);
|
||||
assert!(request(d.path(), "bob@example.test/r", &sub).contains("subscribed"));
|
||||
let p = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='test'><item id='a'><data xmlns='urn:test'>abc</data></item></publish>",
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&p).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.deliveries.len(), 1);
|
||||
assert!(
|
||||
result
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|d| d.0 == "bob@example.test/r")
|
||||
);
|
||||
assert!(
|
||||
request(
|
||||
d.path(),
|
||||
"bob@example.test/r",
|
||||
&iq("get", "alice@example.test", "<items node='test'/>")
|
||||
)
|
||||
.contains("abc")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn roster_presence_access_and_foreign_subscription_denied() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let p = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='test'><item id='a'><x xmlns='urn:test'/></item></publish>",
|
||||
);
|
||||
request(d.path(), "alice@example.test/r", &p);
|
||||
let mut roster = V::table();
|
||||
let mut contact = V::table();
|
||||
contact.set("subscription", "from".into());
|
||||
roster.set("bob@example.test", contact);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "roster", &roster).unwrap();
|
||||
assert!(
|
||||
request(
|
||||
d.path(),
|
||||
"bob@example.test/r",
|
||||
&iq("get", "alice@example.test", "<items node='test'/>")
|
||||
)
|
||||
.contains("type='result'")
|
||||
);
|
||||
assert!(
|
||||
request(
|
||||
d.path(),
|
||||
"bob@example.test/r",
|
||||
&iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<subscribe node='test' jid='eve@example.test'/>"
|
||||
)
|
||||
)
|
||||
.contains("forbidden")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn configure_requires_owner() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
request(
|
||||
d.path(),
|
||||
"alice@example.test/r",
|
||||
&iq("set", "alice@example.test", "<create node='test'/>"),
|
||||
);
|
||||
let x = "<iq type='set' to='alice@example.test' id='x'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><delete node='test'/></pubsub></iq>";
|
||||
assert!(request(d.path(), "bob@example.test/r", x).contains("forbidden"));
|
||||
assert!(request(d.path(), "alice@example.test/r", x).contains("type='result'"));
|
||||
assert!(
|
||||
request(
|
||||
d.path(),
|
||||
"alice@example.test/r",
|
||||
&iq("get", "alice@example.test", "<items node='test'/>")
|
||||
)
|
||||
.contains("item-not-found")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn reads_prosody_legacy_fixture() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let p = storage::path(d.path(), "example.test", Some("alice"), "pep");
|
||||
storage::atomic_write(&p,b"return { ['urn:old']={name='urn:old';config={access_model='open';max_items=1};subscribers={};affiliations={['alice@example.test']='owner'}}};").unwrap();
|
||||
let archive = storage::path(d.path(), "example.test", Some("alice"), "pep_urn:old")
|
||||
.with_extension("list");
|
||||
storage::atomic_write(&archive,b"item({name='payload';attr={xmlns='urn:old';stamp='2020-01-01T00:00:00Z'};[1]='persisted';key='current';when=1577836800;with='alice@example.test'});\n").unwrap();
|
||||
let out = request(
|
||||
d.path(),
|
||||
"bob@example.test/r",
|
||||
&iq("get", "alice@example.test", "<items node='urn:old'/>"),
|
||||
);
|
||||
assert!(out.contains("persisted"), "{out}");
|
||||
let doc = roxmltree::Document::parse(&out).unwrap();
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:old", "payload")))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn configurable_limits_component_creation_and_publisher() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let options = pubsub::Options {
|
||||
max_items: 10000,
|
||||
restrict_node_creation: false,
|
||||
autocreate_on_publish: false,
|
||||
};
|
||||
let run = |actor: &str, xml: &str| {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
pubsub::handle_component_with_options(
|
||||
d.path(),
|
||||
"pubsub.example.test",
|
||||
actor,
|
||||
&[],
|
||||
doc.root_element(),
|
||||
&options,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies
|
||||
.join("")
|
||||
};
|
||||
assert!(run("alice@example.test/r",&iq("set","pubsub.example.test","<create node='n'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#max_items'><value>10000</value></field></x></configure>")).contains("type='result'"));
|
||||
let set = "<iq type='set' to='pubsub.example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><affiliations node='n'><affiliation jid='bob@example.test' affiliation='publisher'/></affiliations></pubsub></iq>";
|
||||
assert!(run("alice@example.test/r", set).contains("type='result'"));
|
||||
let publish = iq(
|
||||
"set",
|
||||
"pubsub.example.test",
|
||||
"<publish node='n'><item id='a'><x xmlns='urn:test'/></item></publish>",
|
||||
);
|
||||
assert!(run("bob@example.test/r", &publish).contains("type='result'"));
|
||||
assert!(run("eve@example.test/r", &publish).contains("forbidden"));
|
||||
}
|
||||
#[test]
|
||||
fn capability_discovery_replays_and_filters_notifications() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
account(d.path(), "bob");
|
||||
let mut a = V::table();
|
||||
let mut c = V::table();
|
||||
c.set("subscription", "from".into());
|
||||
a.set("bob@example.test", c);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "roster", &a).unwrap();
|
||||
let mut b = V::table();
|
||||
let mut c = V::table();
|
||||
c.set("subscription", "to".into());
|
||||
b.set("alice@example.test", c);
|
||||
storage::save(d.path(), "example.test", Some("bob"), "roster", &b).unwrap();
|
||||
let publish = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='urn:test'><item id='one'><x xmlns='urn:test'>hello</x></item></publish>",
|
||||
);
|
||||
request(d.path(), "alice@example.test/r", &publish);
|
||||
let presence=roxmltree::Document::parse("<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='urn:client' ver='abc'/></presence>").unwrap();
|
||||
let query = pubsub::on_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@example.test/r",
|
||||
presence.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(query.deliveries.len(), 1);
|
||||
let querydoc = roxmltree::Document::parse(&query.deliveries[0].1).unwrap();
|
||||
let id = querydoc.root_element().attribute("id").unwrap();
|
||||
let response = format!(
|
||||
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='urn:test+notify'/></query></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&response).unwrap();
|
||||
assert!(
|
||||
pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"eve@example.test/r",
|
||||
doc.root_element()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let replay = pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(replay.deliveries.len(), 1);
|
||||
assert!(replay.deliveries[0].1.contains("hello"));
|
||||
let doc = roxmltree::Document::parse(&publish).unwrap();
|
||||
let events = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
events
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|d| d.0 == "bob@example.test/r")
|
||||
);
|
||||
storage::save(
|
||||
d.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"roster",
|
||||
&V::table(),
|
||||
)
|
||||
.unwrap();
|
||||
let events = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!events
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|d| d.0 == "bob@example.test/r")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn subscription_options_disable_events_and_require_subscriber() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
request(
|
||||
d.path(),
|
||||
"alice@example.test/r",
|
||||
&iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<create node='n'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#access_model'><value>open</value></field></x></configure>",
|
||||
),
|
||||
);
|
||||
let sub = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<subscribe node='n' jid='bob@example.test/r'/><options><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#subscribe_options</value></field><field var='pubsub#deliver'><value>0</value></field></x></options>",
|
||||
);
|
||||
assert!(request(d.path(), "bob@example.test/r", &sub).contains("subscribed"));
|
||||
let get = iq(
|
||||
"get",
|
||||
"alice@example.test",
|
||||
"<options node='n' jid='bob@example.test/r'/>",
|
||||
);
|
||||
assert!(request(d.path(), "bob@example.test/r", &get).contains("<value>0</value>"));
|
||||
assert!(request(d.path(), "eve@example.test/r", &get).contains("forbidden"));
|
||||
let pubxml = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='n'><item id='x'><x xmlns='urn:test'/></item></publish>",
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&pubxml).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!result
|
||||
.deliveries
|
||||
.iter()
|
||||
.any(|d| d.0 == "bob@example.test/r")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn nonpersistent_items_stay_in_memory_and_can_be_made_persistent() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let create = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<create node='n'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#persist_items'><value>0</value></field></x></configure>",
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'"));
|
||||
let p = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='n'><item id='a'><x xmlns='urn:test'>memory-only</x></item></publish>",
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &p).contains("type='result'"));
|
||||
let get = iq("get", "alice@example.test", "<items node='n'/>");
|
||||
assert!(request(d.path(), "alice@example.test/r", &get).contains("memory-only"));
|
||||
assert!(
|
||||
rust_xmpp::archive::load_store(d.path(), "example.test", Some("alice"), "pep_n")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let config = "<iq type='set' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='n'><x xmlns='jabber:x:data' type='submit'><field var='pubsub#persist_items'><value>1</value></field></x></configure></pubsub></iq>";
|
||||
assert!(request(d.path(), "alice@example.test/r", config).contains("type='result'"));
|
||||
assert_eq!(
|
||||
rust_xmpp::archive::load_store(d.path(), "example.test", Some("alice"), "pep_n")
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn modern_bookmarks_notify_legacy_capability() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let presence=roxmltree::Document::parse("<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='client' ver='v'/></presence>").unwrap();
|
||||
let q = pubsub::on_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/old",
|
||||
presence.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
let qdoc = roxmltree::Document::parse(&q.deliveries[0].1).unwrap();
|
||||
let id = qdoc.root_element().attribute("id").unwrap();
|
||||
let answer = format!(
|
||||
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='storage:bookmarks+notify'/></query></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&answer).unwrap();
|
||||
pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/old",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
let publish = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='urn:xmpp:bookmarks:1'><item id='room@conference.example.test'><conference xmlns='urn:xmpp:bookmarks:1' name='Room' autojoin='true'><nick>Alice</nick></conference></item></publish>",
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&publish).unwrap();
|
||||
let events = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/new",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let legacy = events
|
||||
.deliveries
|
||||
.iter()
|
||||
.find(|d| d.0 == "alice@example.test/old")
|
||||
.unwrap();
|
||||
assert!(legacy.1.contains("storage:bookmarks"));
|
||||
assert!(legacy.1.contains("jid='room@conference.example.test'"));
|
||||
assert!(legacy.1.contains("<nick>Alice</nick>"));
|
||||
}
|
||||
#[test]
|
||||
fn remote_caps_replay_and_open_node_privacy_revoke() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let create = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<create node='urn:test'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#access_model'><value>open</value></field></x></configure>",
|
||||
);
|
||||
request(d.path(), "alice@example.test/r", &create);
|
||||
let p = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='urn:test'><item id='x'><x xmlns='urn:test'>remote payload</x></item></publish>",
|
||||
);
|
||||
request(d.path(), "alice@example.test/r", &p);
|
||||
let presence=roxmltree::Document::parse("<presence from='bob@remote.test/r' to='alice@example.test'><c xmlns='http://jabber.org/protocol/caps' node='client' ver='v' hash='sha-1'/></presence>").unwrap();
|
||||
assert!(
|
||||
pubsub::on_remote_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@remote.test/r",
|
||||
presence.root_element()
|
||||
)
|
||||
.unwrap()
|
||||
.deliveries
|
||||
.is_empty()
|
||||
);
|
||||
let mut roster = V::table();
|
||||
let mut c = V::table();
|
||||
c.set("subscription", "from".into());
|
||||
roster.set("bob@remote.test", c);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "roster", &roster).unwrap();
|
||||
let q = pubsub::on_remote_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@remote.test/r",
|
||||
presence.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(q.deliveries.len(), 1);
|
||||
let doc = roxmltree::Document::parse(&q.deliveries[0].1).unwrap();
|
||||
let id = doc.root_element().attribute("id").unwrap();
|
||||
let answer = format!(
|
||||
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='urn:test+notify'/></query></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&answer).unwrap();
|
||||
assert!(
|
||||
pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"mallory@remote.test/r",
|
||||
doc.root_element()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let result = pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@remote.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.deliveries.len(), 1);
|
||||
assert!(result.deliveries[0].1.contains("remote payload"));
|
||||
let doc = roxmltree::Document::parse(&p).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(result.deliveries.iter().any(|e| e.0 == "bob@remote.test/r"));
|
||||
storage::save(
|
||||
d.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"roster",
|
||||
&V::table(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!result.deliveries.iter().any(|e| e.0 == "bob@remote.test/r"));
|
||||
}
|
||||
#[test]
|
||||
fn component_default_retention_matches_prosody_twenty_items() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let options = pubsub::Options {
|
||||
restrict_node_creation: false,
|
||||
..Default::default()
|
||||
};
|
||||
let run = |xml: String| {
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
pubsub::handle_component_with_options(
|
||||
d.path(),
|
||||
"pubsub.example.test",
|
||||
"alice@example.test/r",
|
||||
&[],
|
||||
doc.root_element(),
|
||||
&options,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
run(iq("set", "pubsub.example.test", "<create node='n'/>"));
|
||||
for i in 0..22 {
|
||||
run(iq(
|
||||
"set",
|
||||
"pubsub.example.test",
|
||||
&format!("<publish node='n'><item id='{i}'><x xmlns='urn:test'/></item></publish>"),
|
||||
));
|
||||
}
|
||||
let entries =
|
||||
rust_xmpp::archive::load_store(d.path(), "pubsub.example.test", None, "pubsub_n").unwrap();
|
||||
assert_eq!(entries.len(), 20);
|
||||
assert_eq!(entries[0].id, "2");
|
||||
}
|
||||
#[test]
|
||||
fn remote_unavailable_is_scoped_to_addressed_owner_and_cancels_pending() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
for owner in ["alice", "carol"] {
|
||||
account(d.path(), owner);
|
||||
let mut roster = V::table();
|
||||
let mut c = V::table();
|
||||
c.set("subscription", "from".into());
|
||||
roster.set("bob@remote.test", c);
|
||||
storage::save(d.path(), "example.test", Some(owner), "roster", &roster).unwrap();
|
||||
}
|
||||
let mut ids = vec![];
|
||||
for owner in ["alice", "carol"] {
|
||||
let p = format!(
|
||||
"<presence from='bob@remote.test/r' to='{owner}@example.test'><c xmlns='http://jabber.org/protocol/caps' ver='v'/></presence>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&p).unwrap();
|
||||
let q = pubsub::on_remote_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@remote.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
let qdoc = roxmltree::Document::parse(&q.deliveries[0].1).unwrap();
|
||||
ids.push(qdoc.root_element().attribute("id").unwrap().to_owned());
|
||||
}
|
||||
let unavailable = roxmltree::Document::parse(
|
||||
"<presence from='bob@remote.test' to='alice@example.test' type='unavailable'/>",
|
||||
)
|
||||
.unwrap();
|
||||
pubsub::on_remote_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@remote.test",
|
||||
unavailable.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let answer = format!(
|
||||
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='urn:test+notify'/></query></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&answer).unwrap();
|
||||
let result = pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"bob@remote.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.is_some(), i == 1);
|
||||
}
|
||||
for owner in ["alice", "carol"] {
|
||||
let p = iq(
|
||||
"set",
|
||||
&format!("{owner}@example.test"),
|
||||
"<publish node='urn:test'><item id='x'><x xmlns='urn:test'/></item></publish>",
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&p).unwrap();
|
||||
let r = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
&format!("{owner}@example.test/r"),
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r.deliveries.iter().any(|e| e.0 == "bob@remote.test/r"),
|
||||
owner == "carol"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_policy_applies_to_subscription_and_caps_replays() {
|
||||
for (node, advertised, payload) in [
|
||||
(
|
||||
"urn:test:private",
|
||||
"urn:test:private",
|
||||
"<secret xmlns='urn:test'>payload-secret</secret>",
|
||||
),
|
||||
(
|
||||
"urn:xmpp:bookmarks:1",
|
||||
"storage:bookmarks",
|
||||
"<conference xmlns='urn:xmpp:bookmarks:1' name='payload-secret'/>",
|
||||
),
|
||||
] {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let create = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
&format!(
|
||||
"<create node='{node}'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#include_payload'><value>0</value></field></x></configure>"
|
||||
),
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'"));
|
||||
let publish = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
&format!(
|
||||
"<publish node='{node}'><item id='room@conference.example.test'>{payload}</item></publish>"
|
||||
),
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &publish).contains("type='result'"));
|
||||
let subscribe = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
&format!("<subscribe node='{node}' jid='alice@example.test/subscriber'/>"),
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&subscribe).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.deliveries.len(), 1);
|
||||
assert!(
|
||||
result.deliveries[0]
|
||||
.1
|
||||
.contains("<item id='room@conference.example.test'/>"),
|
||||
"{:?}",
|
||||
result.deliveries
|
||||
);
|
||||
assert!(!result.deliveries[0].1.contains("payload-secret"));
|
||||
|
||||
let presence = roxmltree::Document::parse(
|
||||
"<presence><c xmlns='http://jabber.org/protocol/caps' ver='v'/></presence>",
|
||||
)
|
||||
.unwrap();
|
||||
let query = pubsub::on_presence(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/caps",
|
||||
presence.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
let query_doc = roxmltree::Document::parse(&query.deliveries[0].1).unwrap();
|
||||
let id = query_doc.root_element().attribute("id").unwrap();
|
||||
let answer = format!(
|
||||
"<iq type='result' id='{id}'><query xmlns='http://jabber.org/protocol/disco#info'><feature var='{advertised}+notify'/></query></iq>"
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&answer).unwrap();
|
||||
let result = pubsub::on_disco_result(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/caps",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.deliveries.len(), 1);
|
||||
assert!(
|
||||
!result.deliveries[0].1.contains("payload-secret"),
|
||||
"{:?}",
|
||||
result.deliveries
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&publish).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.deliveries.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.deliveries
|
||||
.iter()
|
||||
.all(|(_, xml)| !xml.contains("payload-secret"))
|
||||
);
|
||||
// Omitting notification payload does not discard the stored item.
|
||||
let get = iq(
|
||||
"get",
|
||||
"alice@example.test",
|
||||
&format!("<items node='{node}'/>"),
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &get).contains("payload-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_options_conflict_is_recoverable_and_preserves_item() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let node = "urn:xmpp:omemo:2:devices";
|
||||
let publish = |value: &str, access: &str| {
|
||||
iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
&format!(
|
||||
"<publish node='{node}'><item id='current'><devices xmlns='urn:xmpp:omemo:2'>{value}</devices></item></publish><publish-options><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field><field var='pubsub#access_model'><value>{access}</value></field></x></publish-options>"
|
||||
),
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
request(
|
||||
d.path(),
|
||||
"alice@example.test/r",
|
||||
&publish("original", "presence")
|
||||
)
|
||||
.contains("type='result'")
|
||||
);
|
||||
let reply = request(
|
||||
d.path(),
|
||||
"alice@example.test/r",
|
||||
&publish("replacement", "open"),
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&reply).unwrap();
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.has_tag_name(("urn:ietf:params:xml:ns:xmpp-stanzas", "conflict")))
|
||||
);
|
||||
assert!(doc.descendants().any(|n| n.has_tag_name((
|
||||
"http://jabber.org/protocol/pubsub#errors",
|
||||
"precondition-not-met"
|
||||
))));
|
||||
let get = iq(
|
||||
"get",
|
||||
"alice@example.test",
|
||||
&format!("<items node='{node}'/>"),
|
||||
);
|
||||
let reply = request(d.path(), "alice@example.test/r", &get);
|
||||
assert!(reply.contains("original"));
|
||||
assert!(!reply.contains("replacement"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_jid_block_applies_to_reads_subscription_replays_and_events() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
account(d.path(), "bob");
|
||||
let create = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<create node='n'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#access_model'><value>open</value></field></x></configure>",
|
||||
);
|
||||
assert!(request(d.path(), "alice@example.test/r", &create).contains("type='result'"));
|
||||
let subscribe = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<subscribe node='n' jid='bob@example.test/blocked'/>",
|
||||
);
|
||||
assert!(request(d.path(), "bob@example.test/blocked", &subscribe).contains("subscribed"));
|
||||
let publish = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
"<publish node='n'><item id='one'><x xmlns='urn:test'>secret</x></item></publish>",
|
||||
);
|
||||
request(d.path(), "alice@example.test/r", &publish);
|
||||
let mut blocked = V::table();
|
||||
blocked.set("bob@example.test/blocked", V::Bool(true));
|
||||
storage::save(
|
||||
d.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"blocklist",
|
||||
&blocked,
|
||||
)
|
||||
.unwrap();
|
||||
let get = iq("get", "alice@example.test", "<items node='n'/>");
|
||||
assert!(request(d.path(), "bob@example.test/blocked", &get).contains("forbidden"));
|
||||
assert!(request(d.path(), "bob@example.test/allowed", &get).contains("secret"));
|
||||
// A different resource must not cause a replay to the blocked resource.
|
||||
assert!(request(d.path(), "bob@example.test/allowed", &subscribe).contains("forbidden"));
|
||||
let doc = roxmltree::Document::parse(&publish).unwrap();
|
||||
let result = pubsub::handle(
|
||||
d.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
doc.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(result.deliveries.is_empty(), "{:?}", result.deliveries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_string_max_items_satisfies_numeric_publish_precondition() {
|
||||
for maximum in ["1", "max"] {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let mut config = V::table();
|
||||
config.set("max_items", maximum.into());
|
||||
let mut node = V::table();
|
||||
node.set("name", "urn:test:devices".into());
|
||||
node.set("config", config);
|
||||
let mut metadata = V::table();
|
||||
metadata.set("urn:test:devices", node);
|
||||
storage::save(d.path(), "example.test", Some("alice"), "pep", &metadata).unwrap();
|
||||
let publish = iq(
|
||||
"set",
|
||||
"alice@example.test",
|
||||
&format!(
|
||||
"<publish node='urn:test:devices'><item id='current'><devices xmlns='urn:test:devices'/></item></publish><publish-options><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field><field var='pubsub#max_items'><value>{maximum}</value></field></x></publish-options>"
|
||||
),
|
||||
);
|
||||
let result = request(d.path(), "alice@example.test/r", &publish);
|
||||
assert!(result.contains("type='result'"), "{result}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_notification_fields_and_retract_policy_are_independent() {
|
||||
for component in [false, true] {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
account(d.path(), "alice");
|
||||
let target = if component {
|
||||
"pubsub.example.test"
|
||||
} else {
|
||||
"alice@example.test"
|
||||
};
|
||||
let options = pubsub::Options {
|
||||
restrict_node_creation: false,
|
||||
..Default::default()
|
||||
};
|
||||
let run = |actor: &str, xml: &str| {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
if component {
|
||||
pubsub::handle_component_with_options(
|
||||
d.path(),
|
||||
target,
|
||||
actor,
|
||||
&[],
|
||||
doc.root_element(),
|
||||
&options,
|
||||
)
|
||||
} else {
|
||||
pubsub::handle(d.path(), "example.test", actor, doc.root_element())
|
||||
}
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let create = iq(
|
||||
"set",
|
||||
target,
|
||||
"<create node='n'/><configure><x xmlns='jabber:x:data' type='submit'><field var='pubsub#access_model'><value>open</value></field><field var='pubsub#deliver_notifications'><value>0</value></field><field var='pubsub#deliver_payloads'><value>0</value></field><field var='pubsub#notify_retract'><value>1</value></field></x></configure>",
|
||||
);
|
||||
assert!(run("alice@example.test/r", &create).replies[0].contains("type='result'"));
|
||||
run(
|
||||
"bob@example.test/r",
|
||||
&iq(
|
||||
"set",
|
||||
target,
|
||||
"<subscribe node='n' jid='bob@example.test/r'/>",
|
||||
),
|
||||
);
|
||||
let publish = iq(
|
||||
"set",
|
||||
target,
|
||||
"<publish node='n'><item id='one'><x xmlns='urn:test'>secret</x></item></publish>",
|
||||
);
|
||||
let retract = iq(
|
||||
"set",
|
||||
target,
|
||||
"<retract node='n' notify='true'><item id='one'/></retract>",
|
||||
);
|
||||
assert!(run("alice@example.test/r", &publish).deliveries.is_empty());
|
||||
let result = run("alice@example.test/r", &retract);
|
||||
assert_eq!(result.deliveries.len(), 1, "{:?}", result);
|
||||
assert!(
|
||||
result.deliveries[0]
|
||||
.1
|
||||
.contains("<items node='n'><retract id='one'/></items>")
|
||||
);
|
||||
|
||||
let configure = format!(
|
||||
"<iq type='set' to='{target}'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='n'><x xmlns='jabber:x:data' type='submit'><field var='pubsub#deliver_notifications'><value>1</value></field><field var='pubsub#notify_retract'><value>0</value></field></x></configure></pubsub></iq>"
|
||||
);
|
||||
run("alice@example.test/r", &configure);
|
||||
let result = run("alice@example.test/r", &publish);
|
||||
assert_eq!(result.deliveries.len(), 1);
|
||||
assert!(!result.deliveries[0].1.contains("secret"));
|
||||
assert!(run("alice@example.test/r", &retract).deliveries.is_empty());
|
||||
let get = format!(
|
||||
"<iq type='get' to='{target}'><pubsub xmlns='http://jabber.org/protocol/pubsub#owner'><configure node='n'/></pubsub></iq>"
|
||||
);
|
||||
let result = run("alice@example.test/r", &get);
|
||||
assert!(result.replies[0].contains("pubsub#deliver_notifications"));
|
||||
assert!(result.replies[0].contains("pubsub#deliver_payloads"));
|
||||
assert!(!result.replies[0].contains("pubsub#notify_items"));
|
||||
assert!(!result.replies[0].contains("pubsub#include_payload"));
|
||||
}
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
use rust_xmpp::{auth, config::Config, push::Push, storage};
|
||||
fn config() -> Config {
|
||||
serde_json::from_value(serde_json::json!({"global":{"modules_enabled":["cloud_notify"],"push_notification_with_body":false,"push_notification_with_sender":true,"push_notification_important_body":"New message"},"hosts":{"example.test":{"options":{}}}})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn registrations_notifications_and_authenticated_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
auth::create(dir.path(), "example.test", "alice", "secret", "SHA-256").unwrap();
|
||||
let mut push = Push::default();
|
||||
let enable=roxmltree::Document::parse("<iq type='set' id='enable'><enable xmlns='urn:xmpp:push:0' jid='push.test' node='device'><x xmlns='jabber:x:data' type='submit'><field var='secret'><value>token</value></field></x></enable></iq>").unwrap();
|
||||
assert!(
|
||||
push.handle(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice@example.test/mobile",
|
||||
enable.root_element(),
|
||||
50
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.replies[0]
|
||||
.contains("type='result'")
|
||||
);
|
||||
let data = storage::load(dir.path(), "example.test", Some("alice"), "cloud_notify")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(data.get("push.test<device").is_some());
|
||||
let message=roxmltree::Document::parse("<message xmlns='jabber:client' from='bob@example.test'><body>private text</body></message>").unwrap();
|
||||
for (i, sender) in ["forged.test", "push.test", "push.test"].iter().enumerate() {
|
||||
let notifications = push
|
||||
.notify(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
message.root_element(),
|
||||
&config(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
let xml = ¬ifications[0].1;
|
||||
assert!(!xml.contains("private text"));
|
||||
assert!(xml.contains("New message") && xml.contains("token"));
|
||||
let d = roxmltree::Document::parse(xml).unwrap();
|
||||
let id = d.root_element().attribute("id").unwrap();
|
||||
let reply =
|
||||
format!("<iq type='error' id='{id}' from='{sender}'><error type='cancel'/></iq>");
|
||||
let d = roxmltree::Document::parse(&reply).unwrap();
|
||||
assert_eq!(
|
||||
push.result(dir.path(), "example.test", d.root_element(), 2)
|
||||
.unwrap(),
|
||||
i > 0
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
storage::load(dir.path(), "example.test", Some("alice"), "cloud_notify")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_account_cannot_register() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut push = Push::default();
|
||||
let d=roxmltree::Document::parse("<iq type='set' to='bob@example.test'><enable xmlns='urn:xmpp:push:0' jid='push.test'/></iq>").unwrap();
|
||||
let r = push
|
||||
.handle(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
d.root_element(),
|
||||
50,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(r.replies[0].contains("forbidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_client_registration_and_targeted_notification() {
|
||||
use rust_xmpp::push::client_device;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut push = Push::default();
|
||||
for (node, resource, client) in [
|
||||
("phone", "old", Some("stable-phone")),
|
||||
("tablet", "tablet", None),
|
||||
] {
|
||||
let xml = format!(
|
||||
"<iq type='set' xml:lang='ru'><enable xmlns='urn:xmpp:push:0' jid='push.test' node='{node}'/></iq>"
|
||||
);
|
||||
let document = roxmltree::Document::parse(&xml).unwrap();
|
||||
push.handle_with_client(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
&format!("alice@example.test/{resource}"),
|
||||
document.root_element(),
|
||||
50,
|
||||
client,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let table = storage::load(dir.path(), "example.test", Some("alice"), "cloud_notify")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let phone = table.get("push.test<phone").unwrap();
|
||||
assert!(phone.get("client_id").is_some());
|
||||
assert!(phone.get("resource").is_none());
|
||||
assert!(phone.get("language").is_some());
|
||||
assert_eq!(
|
||||
client_device(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
Some("stable-phone"),
|
||||
"new"
|
||||
)
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("push.test<phone")
|
||||
);
|
||||
assert_eq!(
|
||||
client_device(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
Some("other-device"),
|
||||
"old"
|
||||
)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
client_device(dir.path(), "example.test", "alice", None, "tablet")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("push.test<tablet")
|
||||
);
|
||||
let message = roxmltree::Document::parse("<message><body>hello</body></message>").unwrap();
|
||||
let sent = push
|
||||
.notify_for_identifier(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
message.root_element(),
|
||||
&config(),
|
||||
Some("push.test<phone"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert!(sent[0].1.contains("node='phone'"));
|
||||
assert!(
|
||||
push.notify_for_identifier(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
message.root_element(),
|
||||
&config(),
|
||||
Some("missing")
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
push.notify(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
message.root_element(),
|
||||
&config()
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn queue_coalesces_and_importance_matches_native() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut push = Push::default();
|
||||
let enable = roxmltree::Document::parse(
|
||||
"<iq type='set'><enable xmlns='urn:xmpp:push:0' jid='push.test' node='device'/></iq>",
|
||||
)
|
||||
.unwrap();
|
||||
push.handle(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
enable.root_element(),
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
let messages = vec![
|
||||
"<message type='headline'><body>not important</body></message>".into(),
|
||||
"<message><active xmlns='http://jabber.org/protocol/chatstates'/></message>".into(),
|
||||
"<message><body>important</body></message>".into(),
|
||||
"<message><body>second important</body></message>".into(),
|
||||
];
|
||||
let result = push
|
||||
.notify_queue(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
&messages,
|
||||
&config(),
|
||||
"push.test<device",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.deliveries.len(), 2);
|
||||
assert!(result.reset_watchdog);
|
||||
assert!(!result.deliveries[0].1.contains("last-message-body"));
|
||||
assert!(result.deliveries[1].1.contains("last-message-body"));
|
||||
for (xml, expected) in [
|
||||
(
|
||||
"<message type='groupchat'><subject>Topic</subject><body>topic text</body></message>",
|
||||
false,
|
||||
),
|
||||
("<message><x xmlns='jabber:x:encrypted'/></message>", true),
|
||||
(
|
||||
"<message><finish xmlns='urn:xmpp:jingle-message:0'/></message>",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"<message><propose xmlns='urn:xmpp:jingle-message:0'/></message>",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"<message><sent xmlns='urn:xmpp:carbons:2'><forwarded xmlns='urn:xmpp:forward:0'><message xmlns='jabber:client'><body>outgoing</body></message></forwarded></sent></message>",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(xml).unwrap();
|
||||
assert_eq!(
|
||||
rust_xmpp::push::is_important(doc.root_element()),
|
||||
expected,
|
||||
"{xml}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn reenable_cancels_old_errors_and_failure_counts_are_per_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut push = Push::default();
|
||||
let enable = roxmltree::Document::parse(
|
||||
"<iq type='set'><enable xmlns='urn:xmpp:push:0' jid='push.test' node='shared'/></iq>",
|
||||
)
|
||||
.unwrap();
|
||||
for user in ["alice", "bob"] {
|
||||
push.handle(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
&format!("{user}@example.test/r"),
|
||||
enable.root_element(),
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let message = roxmltree::Document::parse("<message><body>hello</body></message>").unwrap();
|
||||
let fail = |push: &mut Push, user: &str| {
|
||||
let out = push
|
||||
.notify(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
user,
|
||||
message.root_element(),
|
||||
&config(),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = roxmltree::Document::parse(&out[0].1).unwrap();
|
||||
let id = doc.root_element().attribute("id").unwrap();
|
||||
let error =
|
||||
format!("<iq type='error' from='push.test' id='{id}'><error type='cancel'/></iq>");
|
||||
let doc = roxmltree::Document::parse(&error).unwrap();
|
||||
push.result(dir.path(), "example.test", doc.root_element(), 2)
|
||||
.unwrap();
|
||||
};
|
||||
fail(&mut push, "alice");
|
||||
fail(&mut push, "bob");
|
||||
assert!(push.take_removed().is_empty());
|
||||
fail(&mut push, "alice");
|
||||
assert_eq!(
|
||||
push.take_removed(),
|
||||
vec![("alice".into(), "push.test<shared".into())]
|
||||
);
|
||||
assert!(
|
||||
storage::load(dir.path(), "example.test", Some("bob"), "cloud_notify")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
let out = push
|
||||
.notify(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"bob",
|
||||
message.root_element(),
|
||||
&config(),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = roxmltree::Document::parse(&out[0].1).unwrap();
|
||||
let id = doc.root_element().attribute("id").unwrap();
|
||||
let error = format!("<iq type='error' from='push.test' id='{id}'><error type='cancel'/></iq>");
|
||||
push.handle(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"bob@example.test/r",
|
||||
enable.root_element(),
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
let doc = roxmltree::Document::parse(&error).unwrap();
|
||||
assert!(
|
||||
!push
|
||||
.result(dir.path(), "example.test", doc.root_element(), 1)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(push.take_removed().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_summary_never_copies_foreign_namespace_body() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut push = Push::default();
|
||||
let enable = roxmltree::Document::parse(
|
||||
"<iq type='set'><enable xmlns='urn:xmpp:push:0' jid='push.test' node='phone'/></iq>",
|
||||
)
|
||||
.unwrap();
|
||||
push.handle(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
enable.root_element(),
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
let mut settings = config();
|
||||
settings.global.insert(
|
||||
"push_notification_with_body".into(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
for (message, expected) in [
|
||||
(
|
||||
"<message xmlns='jabber:client'><body xmlns='urn:private'>extension-secret</body></message>",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"<message xmlns='jabber:client'><private xmlns='urn:private'><body>nested-secret</body></private></message>",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"<message xmlns='jabber:client'><body xmlns='urn:private'>extension-secret</body><body>visible text</body></message>",
|
||||
Some("visible text"),
|
||||
),
|
||||
(
|
||||
"<message xmlns='jabber:client'><body><private xmlns='urn:private'>nested-secret</private></body></message>",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"<message xmlns='jabber:client'><body>visible <!-- comment -->text</body></message>",
|
||||
Some("visible text"),
|
||||
),
|
||||
(
|
||||
"<message xmlns='jabber:client' type='groupchat'><subject xmlns='urn:private'>extension-secret</subject><body>visible text</body></message>",
|
||||
Some("visible text"),
|
||||
),
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(message).unwrap();
|
||||
let notifications = push
|
||||
.notify(
|
||||
dir.path(),
|
||||
"example.test",
|
||||
"alice",
|
||||
doc.root_element(),
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
let xml = ¬ifications[0].1;
|
||||
assert!(
|
||||
!xml.contains("extension-secret") && !xml.contains("nested-secret"),
|
||||
"foreign body leaked: {xml}"
|
||||
);
|
||||
let result = roxmltree::Document::parse(xml).unwrap();
|
||||
let summary = result.descendants().find(|n| {
|
||||
n.has_tag_name(("jabber:x:data", "field"))
|
||||
&& n.attribute("var") == Some("last-message-body")
|
||||
});
|
||||
let body = summary
|
||||
.and_then(|n| {
|
||||
n.children()
|
||||
.find(|n| n.has_tag_name(("jabber:x:data", "value")))
|
||||
})
|
||||
.and_then(|n| n.text());
|
||||
assert_eq!(body, expected);
|
||||
assert_eq!(
|
||||
rust_xmpp::push::is_important(doc.root_element()),
|
||||
expected.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires PROSODY_REFERENCE_SOURCE and PROSODY_REFERENCE_LUA"]
|
||||
fn native_push_body_lookup_excludes_extensions() {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
let source = std::env::var("PROSODY_REFERENCE_SOURCE").unwrap();
|
||||
let mut lua = Command::new(std::env::var("PROSODY_REFERENCE_LUA").unwrap())
|
||||
.args(["-", &source])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
lua.stdin.take().unwrap().write_all(br#"
|
||||
local source=assert(arg[1]);
|
||||
package.path=source..'/?.lua;'..package.path;
|
||||
package.cpath=source..'/?.so;'..package.cpath;
|
||||
dofile(source..'/loader.lua');
|
||||
local st=require 'prosody.util.stanza';
|
||||
-- mod_cloud_notify uses this real helper for notification bodies and importance.
|
||||
local foreign=st.message({xmlns='jabber:client'}):tag('body',{xmlns='urn:private'}):text('extension-secret'):reset();
|
||||
assert(foreign:get_child_text('body') == nil);
|
||||
local mixed=foreign:tag('body'):text('visible text'):reset();
|
||||
assert(mixed:get_child_text('body') == 'visible text');
|
||||
local nested=st.message({xmlns='jabber:client'}):tag('private',{xmlns='urn:private'}):tag('body'):text('nested-secret'):reset();
|
||||
assert(nested:get_child_text('body') == nil);
|
||||
local embedded=st.message({xmlns='jabber:client'}):tag('body'):tag('private',{xmlns='urn:private'}):text('nested-secret'):reset();
|
||||
assert(embedded:get_child_text('body') == nil);
|
||||
"#).unwrap();
|
||||
let output = lua.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TLS wire regression for XEP-0357 account discovery and registrations.
|
||||
|
||||
Usage: python3 tests/push_discovery.py target/debug/rust-xmpp
|
||||
Uses only Python's standard library and openssl; creates isolated server/data.
|
||||
Actual notification serialization/delivery lifecycle is covered by tests/push.rs.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
DISCO = 'http://jabber.org/protocol/disco#info'
|
||||
PUSH = 'urn:xmpp:push:0'
|
||||
STREAM = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, port, cert):
|
||||
self.sock = socket.create_connection(('127.0.0.1', port), timeout=5)
|
||||
self.buffer = b''
|
||||
self.send(STREAM)
|
||||
self.until(b'</stream:features>')
|
||||
self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
||||
assert b'proceed' in self.until(b'>')
|
||||
self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock, server_hostname='localhost')
|
||||
self.send(STREAM)
|
||||
self.until(b'</stream:features>')
|
||||
credentials = base64.b64encode(b'\0alice\0secret').decode()
|
||||
self.send(f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{credentials}</auth>")
|
||||
assert b'success' in self.until(b'>')
|
||||
self.send(STREAM)
|
||||
self.until(b'</stream:features>')
|
||||
response = self.iq('bind', "<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>phone</resource></bind>", typ='set')
|
||||
assert response.get('type') == 'result', ET.tostring(response)
|
||||
|
||||
def send(self, xml):
|
||||
self.sock.sendall(xml.encode())
|
||||
|
||||
def until(self, marker):
|
||||
while marker not in self.buffer:
|
||||
chunk = self.sock.recv(65536)
|
||||
assert chunk, self.buffer
|
||||
self.buffer += chunk
|
||||
end = self.buffer.index(marker) + len(marker)
|
||||
result, self.buffer = self.buffer[:end], self.buffer[end:]
|
||||
return result
|
||||
|
||||
def iq(self, identifier, payload, to=None, typ='get'):
|
||||
target = f" to='{to}'" if to is not None else ''
|
||||
self.send(f"<iq type='{typ}' id='{identifier}'{target}>{payload}</iq>")
|
||||
while True:
|
||||
response = ET.fromstring(self.until(b'</iq>'))
|
||||
if response.get('id') == identifier:
|
||||
return response
|
||||
|
||||
def disco(self, identifier, to=None):
|
||||
response = self.iq(identifier, f"<query xmlns='{DISCO}'/>", to)
|
||||
assert response.get('type') == 'result', ET.tostring(response)
|
||||
query = response.find('{' + DISCO + '}query')
|
||||
assert query is not None, ET.tostring(response)
|
||||
features = {node.get('var') for node in query.findall('{' + DISCO + '}feature')}
|
||||
identities = {(node.get('category'), node.get('type')) for node in query.findall('{' + DISCO + '}identity')}
|
||||
assert ('pubsub', 'push') not in identities, identities
|
||||
return features, identities
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-push-disco-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for pep in (False, True):
|
||||
for push in (False, True):
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
data = root / f'data-pep{pep}-push{push}'
|
||||
modules = ['tls', 'saslauth'] + (['pep'] if pep else []) + (['cloud_notify'] if push else [])
|
||||
cfg = {'global': {'data_path': str(data), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': modules}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
for user in ('alice', 'bob'):
|
||||
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
client = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
client = Client(port, root/'cert.pem')
|
||||
features, identities = client.disco('own-account', 'alice@localhost')
|
||||
assert (PUSH in features) == push, (pep, push, features)
|
||||
assert ('account', 'registered') in identities, identities
|
||||
monal_pep_features = {'http://jabber.org/protocol/pubsub#' + name for name in ['filtered-notifications','publish-options','publish','subscribe','create-nodes','delete-items','delete-nodes','persistent-items','retrieve-items','auto-create']}
|
||||
assert ((('pubsub','pep') in identities) and monal_pep_features <= features) == pep, (pep, features, identities)
|
||||
if pep:
|
||||
assert {'http://jabber.org/protocol/pubsub#item-ids','http://jabber.org/protocol/pubsub#multi-items','http://jabber.org/protocol/pubsub#config-node-max'} <= features
|
||||
|
||||
# Legacy clients query no-to/domain as well. These remain server discovery.
|
||||
for identifier, target in [('implicit-domain', None), ('domain', 'localhost')]:
|
||||
_, identities = client.disco(identifier, target)
|
||||
assert ('server', 'im') in identities, identities
|
||||
other = client.iq('other-account', f"<query xmlns='{DISCO}'/>", 'bob@localhost')
|
||||
assert all(node.get('var') != PUSH for node in other.iter('{' + DISCO + '}feature')), ET.tostring(other)
|
||||
for node in ('phone', 'tablet'):
|
||||
response = client.iq('enable-' + node, f"<enable xmlns='{PUSH}' jid='push.example.test' node='{node}'><x xmlns='jabber:x:data' type='submit'><field var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field><field var='secret'><value>credential-{node}</value></field></x></enable>", 'alice@localhost', 'set')
|
||||
assert response.get('type') == ('result' if push else 'error'), ET.tostring(response)
|
||||
if push:
|
||||
stored = next(data.glob('*/cloud_notify/*.dat'))
|
||||
text = stored.read_text()
|
||||
assert 'phone' in text and 'tablet' in text and 'credential-phone' in text, text
|
||||
response = client.iq('disable-one', f"<disable xmlns='{PUSH}' jid='push.example.test' node='phone'/>", 'alice@localhost', 'set')
|
||||
assert response.get('type') == 'result', ET.tostring(response)
|
||||
text = stored.read_text()
|
||||
assert 'push.example.test<phone' not in text and 'credential-phone' not in text and 'push.example.test<tablet' in text, text
|
||||
response = client.iq('disable-all', f"<disable xmlns='{PUSH}' jid='push.example.test'/>", 'alice@localhost', 'set')
|
||||
assert response.get('type') == 'result', ET.tostring(response)
|
||||
assert not stored.exists() or 'push.example.test' not in stored.read_text()
|
||||
finally:
|
||||
if client is not None:
|
||||
client.sock.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
if sys.exc_info()[0] is not None:
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
print('PASS: TLS account/domain discovery with PEP and push enabled/disabled; real push enable, publish options, per-node and service-wide disable')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,44 @@
|
||||
All source code in this project is released under the below MIT license. Some
|
||||
components are not authored by the Prosody maintainers, but such code is
|
||||
itself either released under a MIT license or declared public domain.
|
||||
|
||||
---
|
||||
|
||||
Copyright (C) 2008-2022 Matthew Wild
|
||||
Copyright (C) 2008-2020 Waqas Hussain
|
||||
Copyright (C) 2010-2022 Kim Alvefur
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
util-src/encodings.c:
|
||||
Parts included from Lua 5.3. Copyright (C) 1994-2015 Lua.org, PUC-Rio.
|
||||
|
||||
util-src/signal.c:
|
||||
Copyright (C) 2007 Patrick J. Donnelly (batrick@batbytes.com)
|
||||
See full copyright notice in the source file.
|
||||
|
||||
util-src/struct.c:
|
||||
Copyright (C) 2010-2018 Lua.org, PUC-Rio. All rights reserved.
|
||||
See full copyright notice in the source file.
|
||||
|
||||
net/dns.lua:
|
||||
public domain 20080404 lua@ztact.com
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
-- Prosody IM
|
||||
-- Copyright (C) 2008-2010 Matthew Wild
|
||||
-- Copyright (C) 2008-2010 Waqas Hussain
|
||||
--
|
||||
-- This project is MIT/X11 licensed. Please see the
|
||||
-- COPYING file in the source package for more information.
|
||||
--
|
||||
|
||||
local _G = _G;
|
||||
local getmetatable, setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs =
|
||||
getmetatable, setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs;
|
||||
local format, math_max, t_insert = string.format, math.max, table.insert;
|
||||
|
||||
local envload = require"prosody.util.envload".envload;
|
||||
local deps = require"prosody.util.dependencies";
|
||||
local it = require"prosody.util.iterators";
|
||||
local resolve_relative_path = require"prosody.util.paths".resolve_relative_path;
|
||||
local glob_to_pattern = require"prosody.util.paths".glob_to_pattern;
|
||||
local path_sep = package.config:sub(1,1);
|
||||
local get_traceback_table = require "prosody.util.debug".get_traceback_table;
|
||||
local errors = require "prosody.util.error";
|
||||
local log = require "prosody.util.logger".init("config");
|
||||
|
||||
local encodings = deps.softreq"prosody.util.encodings";
|
||||
local nameprep = encodings and encodings.stringprep.nameprep or function (host) return host:lower(); end
|
||||
|
||||
local _M = {};
|
||||
local _ENV = nil;
|
||||
-- luacheck: std none
|
||||
|
||||
_M.resolve_relative_path = resolve_relative_path; -- COMPAT
|
||||
|
||||
local parser = nil;
|
||||
|
||||
local config_mt = { __index = function (t, _) return rawget(t, "*"); end};
|
||||
local config = setmetatable({ ["*"] = { } }, config_mt);
|
||||
local files = {};
|
||||
local credentials_directory = nil;
|
||||
local credential_fallback_fatal = true;
|
||||
|
||||
-- When host not found, use global
|
||||
local host_mt = { __index = function(_, k) return config["*"][k] end }
|
||||
|
||||
function _M.getconfig()
|
||||
return config;
|
||||
end
|
||||
|
||||
function _M.get(host, key)
|
||||
local v = config[host][key];
|
||||
if v and errors.is_error(v) then
|
||||
log("warn", "%s:%d: %s", v.context.filename, v.context.fileline, v.text);
|
||||
return nil;
|
||||
end
|
||||
return v;
|
||||
end
|
||||
function _M.rawget(host, key)
|
||||
local hostconfig = rawget(config, host);
|
||||
if hostconfig then
|
||||
return rawget(hostconfig, key);
|
||||
end
|
||||
end
|
||||
|
||||
local function set(config_table, host, key, value)
|
||||
if host and key then
|
||||
local hostconfig = rawget(config_table, host);
|
||||
if not hostconfig then
|
||||
hostconfig = rawset(config_table, host, setmetatable({}, host_mt))[host];
|
||||
end
|
||||
hostconfig[key] = value;
|
||||
return true;
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
local function rawget_option(config_table, host, key)
|
||||
if host and key then
|
||||
local hostconfig = rawget(config_table, host);
|
||||
if not hostconfig then
|
||||
return nil;
|
||||
end
|
||||
return rawget(hostconfig, key);
|
||||
end
|
||||
end
|
||||
|
||||
function _M.set(host, key, value)
|
||||
return set(config, host, key, value);
|
||||
end
|
||||
|
||||
function _M.load(filename, config_format)
|
||||
config_format = config_format or filename:match("%w+$");
|
||||
|
||||
if config_format == "lua" then
|
||||
local f, err, errno = io.open(filename);
|
||||
if f then
|
||||
local new_config = setmetatable({ ["*"] = { } }, config_mt);
|
||||
local ok, err = parser.load(f:read("*a"), filename, new_config);
|
||||
f:close();
|
||||
if ok then
|
||||
config = new_config;
|
||||
end
|
||||
return ok, "parser", err;
|
||||
end
|
||||
return f, "file", err, errno;
|
||||
end
|
||||
|
||||
if not config_format then
|
||||
return nil, "file", "no parser specified";
|
||||
else
|
||||
return nil, "file", "no parser for "..(config_format);
|
||||
end
|
||||
end
|
||||
|
||||
function _M.files()
|
||||
return files;
|
||||
end
|
||||
|
||||
-- Built-in Lua parser
|
||||
do
|
||||
local pcall = _G.pcall;
|
||||
local function get_line_number(config_file)
|
||||
local tb = get_traceback_table(nil, 2);
|
||||
for i = 1, #tb do
|
||||
if tb[i].info.short_src == config_file then
|
||||
return tb[i].info.currentline;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local config_option_proxy_mt = {
|
||||
__index = setmetatable({
|
||||
append = function (self, value)
|
||||
local original_option = self:value();
|
||||
if original_option == nil then
|
||||
original_option = {};
|
||||
end
|
||||
if type(value) ~= "table" then
|
||||
error("'append' operation expects a list of values to append to the existing list", 2);
|
||||
end
|
||||
if value[1] ~= nil then
|
||||
for _, v in ipairs(value) do
|
||||
t_insert(original_option, v);
|
||||
end
|
||||
else
|
||||
for k, v in pairs(value) do
|
||||
original_option[k] = v;
|
||||
end
|
||||
end
|
||||
set(self.config_table, self.host, self.option_name, original_option);
|
||||
return self;
|
||||
end;
|
||||
value = function (self)
|
||||
return rawget_option(self.config_table, self.host, self.option_name);
|
||||
end;
|
||||
values = function (self)
|
||||
return it.values(self:value());
|
||||
end;
|
||||
}, {
|
||||
__index = function (t, k) --luacheck: ignore 212/t
|
||||
error("Unknown config option operation: '"..k.."'", 2);
|
||||
end;
|
||||
});
|
||||
|
||||
__call = function (self, v2)
|
||||
local v = self:value() or {};
|
||||
if type(v) == "table" and type(v2) == "table" then
|
||||
return self:append(v2);
|
||||
end
|
||||
|
||||
error("Invalid syntax - missing '=' perhaps?", 2);
|
||||
end;
|
||||
};
|
||||
local function is_config_option_proxy(v)
|
||||
return getmetatable(v) == config_option_proxy_mt;
|
||||
end
|
||||
|
||||
-- For reading config values out of files.
|
||||
local function filereader(basepath, defaultmode)
|
||||
return function(filename, mode)
|
||||
local f, err = io.open(resolve_relative_path(basepath, filename));
|
||||
if not f then error(err, 2); end
|
||||
local content, err = f:read(mode or defaultmode);
|
||||
f:close();
|
||||
if not content then error(err, 2); end
|
||||
return content;
|
||||
end
|
||||
end
|
||||
|
||||
-- Collect lines into an array
|
||||
local function linereader(basepath)
|
||||
return function(filename)
|
||||
local ret = {};
|
||||
for line in io.lines(resolve_relative_path(basepath, filename)) do
|
||||
t_insert(ret, line);
|
||||
end
|
||||
return ret;
|
||||
end
|
||||
end
|
||||
|
||||
parser = {};
|
||||
function parser.load(data, config_file, config_table)
|
||||
local set_options = {}; -- set_options[host.."/"..option_name] = true (when the option has been set already in this file)
|
||||
local warnings = {};
|
||||
local env;
|
||||
local config_path = config_file:gsub("[^"..path_sep.."]+$", "");
|
||||
|
||||
-- The ' = true' are needed so as not to set off __newindex when we assign the functions below
|
||||
env = setmetatable({
|
||||
Host = true, host = true, VirtualHost = true,
|
||||
Component = true, component = true,
|
||||
FileContents = true,
|
||||
FileLine = true,
|
||||
FileLines = true,
|
||||
Credential = true,
|
||||
Include = true, include = true, RunScript = true }, {
|
||||
__index = function (_, k)
|
||||
if k:match("^ENV_") then
|
||||
return os.getenv(k:sub(5));
|
||||
end
|
||||
if k == "Lua" then
|
||||
return _G;
|
||||
end
|
||||
local val = rawget_option(config_table, env.__currenthost or "*", k);
|
||||
|
||||
local g_val = rawget(_G, k);
|
||||
|
||||
if val ~= nil or g_val == nil then
|
||||
if type(val) == "table" then
|
||||
return setmetatable({
|
||||
config_table = config_table;
|
||||
host = env.__currenthost or "*";
|
||||
option_name = k;
|
||||
}, config_option_proxy_mt);
|
||||
elseif val == nil then
|
||||
t_insert(
|
||||
warnings,
|
||||
("%s: %d: unrecognized value: %s (you may be missing quotes around it)"):format(
|
||||
config_file,
|
||||
get_line_number(config_file),
|
||||
k
|
||||
)
|
||||
);
|
||||
end
|
||||
|
||||
return val;
|
||||
end
|
||||
|
||||
if g_val ~= nil then
|
||||
t_insert(
|
||||
warnings,
|
||||
("%s:%d: direct usage of the Lua API is deprecated - replace `%s` with `Lua.%s`"):format(
|
||||
config_file,
|
||||
get_line_number(config_file),
|
||||
k,
|
||||
k
|
||||
)
|
||||
);
|
||||
end
|
||||
|
||||
return g_val;
|
||||
end,
|
||||
__newindex = function (_, k, v)
|
||||
local host = env.__currenthost or "*";
|
||||
local option_path = host.."/"..k;
|
||||
if set_options[option_path] then
|
||||
t_insert(warnings, ("%s:%d: Duplicate option '%s'"):format(config_file, get_line_number(config_file), k));
|
||||
end
|
||||
set_options[option_path] = true;
|
||||
if is_config_option_proxy(v) then
|
||||
setmetatable(v, nil);
|
||||
end
|
||||
set(config_table, env.__currenthost or "*", k, v);
|
||||
end
|
||||
});
|
||||
|
||||
rawset(env, "__currenthost", "*") -- Default is global
|
||||
function env.VirtualHost(name)
|
||||
if not name then
|
||||
error("Host must have a name", 2);
|
||||
end
|
||||
local prepped_name = nameprep(name);
|
||||
if not prepped_name then
|
||||
error(format("Name of Host %q contains forbidden characters", name), 0);
|
||||
end
|
||||
name = prepped_name;
|
||||
if rawget(config_table, name) and rawget(config_table[name], "component_module") then
|
||||
error(format("Host %q clashes with previously defined %s Component %q, for services use a sub-domain like conference.%s",
|
||||
name, config_table[name].component_module:gsub("^%a+$", { component = "external", muc = "MUC"}), name, name), 0);
|
||||
end
|
||||
rawset(env, "__currenthost", name);
|
||||
-- Needs at least one setting to logically exist :)
|
||||
set(config_table, name or "*", "defined", true);
|
||||
return function (config_options)
|
||||
rawset(env, "__currenthost", "*"); -- Return to global scope
|
||||
if type(config_options) == "string" then
|
||||
error(format("VirtualHost entries do not accept a module name (module '%s' provided for host '%s')", config_options, name), 2);
|
||||
elseif type(config_options) ~= "table" then
|
||||
error("Invalid syntax following VirtualHost, expected options but received a "..type(config_options), 2);
|
||||
end
|
||||
for option_name, option_value in pairs(config_options) do
|
||||
set(config_table, name or "*", option_name, option_value);
|
||||
end
|
||||
end;
|
||||
end
|
||||
env.Host, env.host = env.VirtualHost, env.VirtualHost;
|
||||
|
||||
function env.Component(name)
|
||||
if not name then
|
||||
error("Component must have a name", 2);
|
||||
end
|
||||
local prepped_name = nameprep(name);
|
||||
if not prepped_name then
|
||||
error(format("Name of Component %q contains forbidden characters", name), 0);
|
||||
end
|
||||
name = prepped_name;
|
||||
if rawget(config_table, name) and rawget(config_table[name], "defined")
|
||||
and not rawget(config_table[name], "component_module") then
|
||||
error(format("Component %q clashes with previously defined VirtualHost %q, for services use a sub-domain like conference.%s",
|
||||
name, name, name), 0);
|
||||
end
|
||||
set(config_table, name, "component_module", "component");
|
||||
-- Don't load the global modules by default
|
||||
set(config_table, name, "load_global_modules", false);
|
||||
rawset(env, "__currenthost", name);
|
||||
local function handle_config_options(config_options)
|
||||
rawset(env, "__currenthost", "*"); -- Return to global scope
|
||||
for option_name, option_value in pairs(config_options) do
|
||||
set(config_table, name or "*", option_name, option_value);
|
||||
end
|
||||
end
|
||||
|
||||
return function (module)
|
||||
if type(module) == "string" then
|
||||
set(config_table, name, "component_module", module);
|
||||
return handle_config_options;
|
||||
end
|
||||
return handle_config_options(module);
|
||||
end
|
||||
end
|
||||
env.component = env.Component;
|
||||
|
||||
function env.Include(file)
|
||||
-- Check whether this is a wildcard Include
|
||||
if file:match("[*?]") then
|
||||
local lfs = deps.softreq "lfs";
|
||||
if not lfs then
|
||||
error(format("Error expanding wildcard pattern in Include %q - LuaFileSystem not available", file));
|
||||
end
|
||||
local path_pos, glob = file:match("()([^"..path_sep.."]+)$");
|
||||
local path = file:sub(1, math_max(path_pos-2,0));
|
||||
if #path > 0 then
|
||||
path = resolve_relative_path(config_path, path);
|
||||
else
|
||||
path = config_path;
|
||||
end
|
||||
local patt = glob_to_pattern(glob);
|
||||
for f in lfs.dir(path) do
|
||||
if f:sub(1,1) ~= "." and f:match(patt) then
|
||||
env.Include(path..path_sep..f);
|
||||
end
|
||||
end
|
||||
return;
|
||||
end
|
||||
-- Not a wildcard, so resolve (potentially) relative path and run through config parser
|
||||
file = resolve_relative_path(config_path, file);
|
||||
local f, err = io.open(file);
|
||||
if f then
|
||||
local ret, err = parser.load(f:read("*a"), file, config_table);
|
||||
if not ret then error(err:gsub("%[string.-%]", file), 0); end
|
||||
if err then
|
||||
for _, warning in ipairs(err) do
|
||||
t_insert(warnings, warning);
|
||||
end
|
||||
end
|
||||
end
|
||||
if not f then error("Error loading included "..file..": "..err, 0); end
|
||||
return f, err;
|
||||
end
|
||||
env.include = env.Include;
|
||||
|
||||
function env.RunScript(file)
|
||||
return dofile(resolve_relative_path(config_path, file));
|
||||
end
|
||||
|
||||
env.FileContents = filereader(config_path, "*a");
|
||||
env.FileLine = filereader(config_path, "*l");
|
||||
env.FileLines = linereader(config_path);
|
||||
|
||||
if credentials_directory then
|
||||
env.Credential = filereader(credentials_directory, "*a");
|
||||
elseif credential_fallback_fatal then
|
||||
env.Credential = function() error("Credential() requires the $CREDENTIALS_DIRECTORY environment variable to be set", 2) end
|
||||
else
|
||||
env.Credential = function()
|
||||
return errors.new({
|
||||
type = "continue";
|
||||
text = "Credential() requires the $CREDENTIALS_DIRECTORY environment variable to be set";
|
||||
}, { filename = config_file; fileline = get_line_number(config_file) });
|
||||
end
|
||||
end
|
||||
|
||||
local chunk, err = envload(data, "@"..config_file, env);
|
||||
|
||||
if not chunk then
|
||||
return nil, err;
|
||||
end
|
||||
|
||||
local ok, err = pcall(chunk);
|
||||
|
||||
if not ok then
|
||||
return nil, err;
|
||||
end
|
||||
|
||||
t_insert(files, config_file);
|
||||
|
||||
return true, warnings;
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
function _M.set_credentials_directory(directory)
|
||||
credentials_directory = directory;
|
||||
end
|
||||
|
||||
function _M.set_credential_fallback_mode(mode)
|
||||
credential_fallback_fatal = mode == "error";
|
||||
end
|
||||
|
||||
return _M;
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Real upstream implementation oracle. Requires a built exact Prosody source and Lua 5.4.
|
||||
use rust_xmpp::{
|
||||
archive, auth,
|
||||
storage::{self, LuaValue as V},
|
||||
};
|
||||
use std::{path::Path, process::Command};
|
||||
fn oracle(root: &Path, phase: &str, hash: &str) {
|
||||
let src = std::env::var("PROSODY_REFERENCE_SOURCE")
|
||||
.expect("set PROSODY_REFERENCE_SOURCE to built source");
|
||||
let lua = std::env::var("PROSODY_REFERENCE_LUA").unwrap_or_else(|_| "lua5.4".into());
|
||||
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/prosody_compat/oracle.lua");
|
||||
let output = Command::new(lua)
|
||||
.args([
|
||||
script.to_str().unwrap(),
|
||||
&src,
|
||||
root.to_str().unwrap(),
|
||||
phase,
|
||||
hash,
|
||||
])
|
||||
.output()
|
||||
.expect("run actual Lua interpreter");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"native Prosody oracle failed:\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
print!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires native supplied Prosody source; run scripts/prosody_compat/verify.sh"]
|
||||
fn real_prosody_storage_and_auth_roundtrip() {
|
||||
for hash in ["SHA-1", "SHA-256"] {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let root = t.path();
|
||||
let host = "example.org";
|
||||
let mut v = V::default();
|
||||
v.set_key(V::Bool(false), "roster".into());
|
||||
v.set_key(V::Integer(3), "sparse".into());
|
||||
v.set("binary", V::String(vec![0, 1, 10, 255]));
|
||||
v.set("text", "Привет & <xml>".into());
|
||||
storage::save(root, host, Some("alice"), "oracle", &v).unwrap();
|
||||
let mut prefs = V::default();
|
||||
prefs.set_key(V::Bool(false), "roster".into());
|
||||
storage::save(root, host, Some("alice"), "archive_prefs", &prefs).unwrap();
|
||||
auth::create(root, host, "alice", "пароль-Original", hash).unwrap();
|
||||
for (i, id) in ["rust-one", "rust-two"].iter().enumerate() {
|
||||
archive::append_new(root,host,Some("alice"),"archive",archive::Entry{id:id.to_string(),when:1767225600.+i as f64,peer:"bob@example.org".into(),xml:"<message xmlns='jabber:client' xml:lang='ru' xmlns:o='urn:oracle' o:flag='yes'><body>Rust body</body></message>".into()}).unwrap();
|
||||
}
|
||||
oracle(root, "roundtrip", hash);
|
||||
let v = storage::load(root, host, Some("alice"), "oracle")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(v.get("binary"), Some(&V::String(vec![0, 1, 10, 255])));
|
||||
assert_eq!(
|
||||
v.get("native").unwrap().get("number"),
|
||||
Some(&V::Number(1.25))
|
||||
);
|
||||
let prefs = storage::load(root, host, Some("alice"), "archive_prefs")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(prefs.get_key(&V::Bool(false)), Some(&V::Bool(false)));
|
||||
assert!(auth::verify(root, host, "alice", "пароль-Native", hash).unwrap());
|
||||
assert!(!auth::verify(root, host, "alice", "пароль-Original", hash).unwrap());
|
||||
assert!(auth::verify(root, host, "bob", "native-bob", hash).unwrap());
|
||||
assert!(auth::verify(root, host, "binary", "binary-password", hash).unwrap());
|
||||
let p = storage::path(root, host, Some("alice"), "archive").with_extension("lidx");
|
||||
assert!(p.exists(), "original Prosody must create real binary index");
|
||||
let rows = archive::load(root, host, "alice").unwrap();
|
||||
assert_eq!(
|
||||
rows.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["rust-one", "native-three", "rust-two"]
|
||||
);
|
||||
let native = roxmltree::Document::parse(&rows[1].xml).unwrap();
|
||||
assert_eq!(
|
||||
native.root_element().attribute(("urn:oracle", "flag")),
|
||||
Some("native")
|
||||
);
|
||||
assert!(rows[2].xml.contains("Replaced by native"));
|
||||
storage::update(root, host, Some("alice"), "oracle", |v| {
|
||||
v.set("rust_final", V::Bool(true));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
auth::set_password(root, host, "bob", "rust-bob-new", hash).unwrap();
|
||||
archive::append_new(
|
||||
root,
|
||||
host,
|
||||
Some("alice"),
|
||||
"archive",
|
||||
archive::Entry {
|
||||
id: "rust-final".into(),
|
||||
when: 1767225604.,
|
||||
peer: "bob@example.org".into(),
|
||||
xml: "<message xmlns='jabber:client'><body>Final</body></message>".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!p.exists());
|
||||
oracle(root, "verify-final", hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated TLS resource-conflict and stream-management replacement regression."""
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from _wire_client import BIN, Client, S
|
||||
|
||||
BIND = 'urn:ietf:params:xml:ns:xmpp-bind'
|
||||
SM = 'urn:xmpp:sm:3'
|
||||
STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas'
|
||||
STREAMS = 'urn:ietf:params:xml:ns:xmpp-streams'
|
||||
|
||||
|
||||
def bind(client, resource='phone', error=False):
|
||||
client.send(f"<iq type='set' id='bind'><bind xmlns='{BIND}'><resource>{resource}</resource></bind></iq>")
|
||||
result = client.iq('bind', error=error)
|
||||
if error:
|
||||
assert result.find(f'{{jabber:client}}error/{{{STANZAS}}}conflict') is not None, ET.tostring(result)
|
||||
return None
|
||||
jid = result.find(f'{{{BIND}}}bind/{{{BIND}}}jid')
|
||||
assert jid is not None and jid.text, ET.tostring(result)
|
||||
client.jid = jid.text
|
||||
return jid.text
|
||||
|
||||
|
||||
def enable_resume(client):
|
||||
client.send(f"<enable xmlns='{SM}' resume='true'/>")
|
||||
enabled = client.take(lambda n: n.tag == f'{{{SM}}}enabled')
|
||||
assert enabled.get('resume') in ('true', '1'), ET.tostring(enabled)
|
||||
assert enabled.get('id'), ET.tostring(enabled)
|
||||
return enabled.get('id')
|
||||
|
||||
|
||||
def resume(client, token, invalid):
|
||||
client.send(f"<resume xmlns='{SM}' previd='{token}' h='0'/>")
|
||||
result = client.take(lambda n: n.tag in (f'{{{SM}}}failed', f'{{{SM}}}resumed'))
|
||||
if invalid:
|
||||
assert result.tag == f'{{{SM}}}failed', ET.tostring(result)
|
||||
assert result.find(f'{{{STANZAS}}}item-not-found') is not None, ET.tostring(result)
|
||||
else:
|
||||
assert result.tag == f'{{{SM}}}resumed' and result.get('previd') == token, ET.tostring(result)
|
||||
client.sync()
|
||||
|
||||
|
||||
def check_policy(port, cert, policy, clients):
|
||||
def connect(user):
|
||||
client = Client(port, cert, user, user + '-secret', bind=False)
|
||||
clients.append(client)
|
||||
return client
|
||||
|
||||
if policy is None:
|
||||
old = connect('carol')
|
||||
assert bind(old) == 'carol@localhost/phone'
|
||||
token = enable_resume(old)
|
||||
# Resume can take over a transport which has not noticed its loss yet.
|
||||
new = connect('carol')
|
||||
resume(new, token, invalid=False)
|
||||
conflict = old.take(lambda n: n.tag == f'{{{S}}}error')
|
||||
assert conflict.find(f'{{{STREAMS}}}conflict') is not None, ET.tostring(conflict)
|
||||
old.close()
|
||||
new.sync()
|
||||
time.sleep(.05)
|
||||
new.sync()
|
||||
|
||||
for sleeping, user in [(False, 'alice'), (True, 'bob')]:
|
||||
old = connect(user)
|
||||
expected = f'{user}@localhost/phone'
|
||||
assert bind(old) == expected
|
||||
old.sync()
|
||||
token = enable_resume(old)
|
||||
if sleeping:
|
||||
# An abrupt TLS transport loss leaves the resumable resource reserved.
|
||||
old.close()
|
||||
time.sleep(.2)
|
||||
|
||||
new = connect(user)
|
||||
replacing = policy in (None, 'kick_old')
|
||||
assigned = bind(new, error=policy == 'kick_new')
|
||||
if replacing:
|
||||
assert assigned == expected, assigned
|
||||
if not sleeping:
|
||||
conflict = old.take(lambda n: n.tag == f'{{{S}}}error')
|
||||
assert conflict.find(f'{{{STREAMS}}}conflict') is not None, ET.tostring(conflict)
|
||||
old.close()
|
||||
# Closing the displaced transport must not remove the new session.
|
||||
new.sync()
|
||||
time.sleep(.05)
|
||||
new.sync()
|
||||
rejected = connect(user)
|
||||
resume(rejected, token, invalid=True)
|
||||
new.sync()
|
||||
elif policy == 'kick_new':
|
||||
if sleeping:
|
||||
survivor = connect(user)
|
||||
resume(survivor, token, invalid=False)
|
||||
else:
|
||||
old.sync()
|
||||
# The rejected stream remains usable and can bind another resource.
|
||||
assert bind(new, 'tablet') == f'{user}@localhost/tablet'
|
||||
new.sync()
|
||||
else:
|
||||
if policy == 'increment':
|
||||
assert assigned == expected + '#1', assigned
|
||||
else:
|
||||
resource = assigned.split('/', 1)[1]
|
||||
assert assigned.startswith(f'{user}@localhost/') and resource != 'phone', assigned
|
||||
assert str(uuid.UUID(resource)) == resource, assigned
|
||||
new.sync()
|
||||
another = connect(user)
|
||||
second = bind(another)
|
||||
if policy == 'increment':
|
||||
assert second == expected + '#2', second
|
||||
else:
|
||||
assert second not in (expected, assigned), second
|
||||
assert str(uuid.UUID(second.split('/', 1)[1])) == second.split('/', 1)[1], second
|
||||
another.sync()
|
||||
# Keeping the original resource includes keeping its resumable session.
|
||||
if sleeping:
|
||||
survivor = connect(user)
|
||||
resume(survivor, token, invalid=False)
|
||||
else:
|
||||
old.sync()
|
||||
|
||||
|
||||
def run_policy(policy):
|
||||
# Same isolated certificate/config/account/server fixture as preauth_network.py.
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-rebind-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||||
'-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'),
|
||||
'-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
options = {'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
||||
'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}
|
||||
if policy is not None:
|
||||
options['conflict_resolve'] = policy
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'],
|
||||
'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [],
|
||||
'modules_enabled': ['tls', 'saslauth', 'ping', 'smacks']},
|
||||
'hosts': {'localhost': {'options': options}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
for user in ['alice', 'bob', 'carol']:
|
||||
subprocess.run([str(BIN), 'create-user', str(config), user, '--password-stdin'],
|
||||
input=user + '-secret\n', text=True, check=True, capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([str(BIN), 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
clients = []
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
check_policy(port, root/'cert.pem', policy, clients)
|
||||
print(f'PASS: TLS conflict_resolve={policy or "default"}, live and hibernating SM resources')
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(log.read())
|
||||
raise
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1:
|
||||
BIN = Path(sys.argv[1]).resolve()
|
||||
for policy in [None, 'kick_old', 'kick_new', 'increment', 'random']:
|
||||
run_policy(policy)
|
||||
@@ -0,0 +1,230 @@
|
||||
use rust_xmpp::sessions::{ConflictPolicy, Hub};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const JID: &str = "alice@example.test/Monal";
|
||||
const TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
#[test]
|
||||
fn concurrent_default_binds_replace_atomically() {
|
||||
let hub = Hub::default();
|
||||
let barrier = Arc::new(Barrier::new(16));
|
||||
let handles: Vec<_> = (0..16)
|
||||
.map(|i| {
|
||||
let hub = hub.clone();
|
||||
let barrier = barrier.clone();
|
||||
std::thread::spawn(move || {
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
barrier.wait();
|
||||
hub.bind_resolving(
|
||||
JID,
|
||||
&format!("connection-{i}"),
|
||||
tx,
|
||||
TTL,
|
||||
ConflictPolicy::from_config(None),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let outcomes: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
assert_eq!(hub.all().len(), 1);
|
||||
assert_eq!(outcomes.iter().filter(|(_, old)| old.is_some()).count(), 15);
|
||||
let current = hub.get(JID).unwrap();
|
||||
for (session, replaced) in outcomes {
|
||||
let state = session.lock().unwrap();
|
||||
assert_eq!(state.closed, !Arc::ptr_eq(&session, ¤t));
|
||||
if let Some(old) = replaced {
|
||||
let old = old.lock().unwrap();
|
||||
assert!(old.closed);
|
||||
assert!(old.sender.is_none());
|
||||
assert!(old.resume_id.is_none());
|
||||
assert!(old.expires.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kick_new_leaves_existing_session_unchanged() {
|
||||
let hub = Hub::default();
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
let old = hub.bind(JID, "old", tx, TTL).unwrap();
|
||||
let id = {
|
||||
let mut state = old.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
state.resume_id.clone().unwrap()
|
||||
};
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
assert!(
|
||||
hub.bind_resolving(JID, "new", tx, TTL, ConflictPolicy::KickNew)
|
||||
.is_err()
|
||||
);
|
||||
assert!(Arc::ptr_eq(&old, &hub.get(JID).unwrap()));
|
||||
let mut state = old.lock().unwrap();
|
||||
assert!(!state.closed);
|
||||
assert_eq!(state.connection, "old");
|
||||
assert_eq!(state.resume_id.as_deref(), Some(id.as_str()));
|
||||
state.transmit("still-connected".into()).unwrap();
|
||||
assert_eq!(rx.try_recv().unwrap(), "still-connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increment_skips_occupied_suffixes_and_preserves_sessions() {
|
||||
let hub = Hub::default();
|
||||
for suffix in ["", "#1", "#2", "#4"] {
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
hub.bind(&format!("{JID}{suffix}"), suffix, tx, TTL)
|
||||
.unwrap();
|
||||
}
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let (session, replaced) = hub
|
||||
.bind_resolving(JID, "new", tx, TTL, ConflictPolicy::Increment)
|
||||
.unwrap();
|
||||
assert_eq!(session.lock().unwrap().jid, format!("{JID}#3"));
|
||||
assert!(replaced.is_none());
|
||||
assert_eq!(hub.all().len(), 5);
|
||||
assert!(hub.all().iter().all(|s| !s.lock().unwrap().closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_conflicts_get_distinct_resources_for_same_owner() {
|
||||
let hub = Hub::default();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let original = hub.bind(JID, "original", tx, TTL).unwrap();
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
names.insert(JID.to_owned());
|
||||
for i in 0..32 {
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let (session, replaced) = hub
|
||||
.bind_resolving(JID, &format!("new-{i}"), tx, TTL, ConflictPolicy::Random)
|
||||
.unwrap();
|
||||
let state = session.lock().unwrap();
|
||||
assert_eq!(state.owner, "alice@example.test");
|
||||
assert!(names.insert(state.jid.clone()));
|
||||
assert!(replaced.is_none());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&original, &hub.get(JID).unwrap()));
|
||||
assert_eq!(hub.all().len(), 33);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_resume_preserves_state_and_replaces_transport_notifier() {
|
||||
let hub = Hub::default();
|
||||
let (tx, _old_rx) = mpsc::channel(8);
|
||||
let original = hub.bind(JID, "old", tx, TTL).unwrap();
|
||||
let (id, old_cancel) = {
|
||||
let mut state = original.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
state.handled = 7;
|
||||
state.client_id = Some("device".into());
|
||||
state.transmit("one".into()).unwrap();
|
||||
state.transmit("two".into()).unwrap();
|
||||
(state.resume_id.clone().unwrap(), state.cancel.clone())
|
||||
};
|
||||
let (tx, mut new_rx) = mpsc::channel(8);
|
||||
let (resumed, replay) = hub.resume("alice@example.test", &id, 1, "new", tx).unwrap();
|
||||
assert!(!Arc::ptr_eq(&original, &resumed));
|
||||
assert!(original.lock().unwrap().closed);
|
||||
assert_eq!(replay.len(), 2);
|
||||
assert!(replay[0].contains("h='7'"));
|
||||
assert_eq!(replay[1], "two");
|
||||
{
|
||||
let mut retired = original.lock().unwrap();
|
||||
assert!(retired.acknowledge(2).is_err());
|
||||
assert!(retired.transmit("stale".into()).is_err());
|
||||
// Even in-flight code holding the retired Arc cannot touch the new counters.
|
||||
retired.handled = 999;
|
||||
retired.pending.push_back("stale".into());
|
||||
}
|
||||
let new_cancel = {
|
||||
let state = resumed.lock().unwrap();
|
||||
assert_eq!((state.handled, state.sent, state.acked), (7, 2, 1));
|
||||
assert_eq!(
|
||||
state.pending.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
vec!["two"]
|
||||
);
|
||||
assert_eq!(state.client_id.as_deref(), Some("device"));
|
||||
assert_eq!(state.resume_id.as_deref(), Some(id.as_str()));
|
||||
assert_eq!(state.connection, "new");
|
||||
assert!(state.expires.is_none());
|
||||
assert!(!state.closed);
|
||||
assert!(!Arc::ptr_eq(&old_cancel, &state.cancel));
|
||||
state.cancel.clone()
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(1), old_cancel.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), new_cancel.notified())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
resumed.lock().unwrap().transmit("three".into()).unwrap();
|
||||
assert_eq!(new_rx.try_recv().unwrap(), "three");
|
||||
for graceful in [false, true] {
|
||||
assert!(hub.disconnect("old", graceful).is_empty());
|
||||
assert!(Arc::ptr_eq(&resumed, &hub.get(JID).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_resume_owner_or_ack_does_not_evict_active_session() {
|
||||
let hub = Hub::default();
|
||||
let (tx, mut old_rx) = mpsc::channel(8);
|
||||
let original = hub.bind(JID, "old", tx, TTL).unwrap();
|
||||
let (id, cancel) = {
|
||||
let mut state = original.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
state.transmit("one".into()).unwrap();
|
||||
(state.resume_id.clone().unwrap(), state.cancel.clone())
|
||||
};
|
||||
for (owner, h) in [("mallory@example.test", 0), ("alice@example.test", 2)] {
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
assert!(hub.resume(owner, &id, h, "intruder", tx).is_err());
|
||||
let state = original.lock().unwrap();
|
||||
assert_eq!(state.connection, "old");
|
||||
assert_eq!((state.sent, state.acked, state.pending.len()), (1, 0, 1));
|
||||
assert!(Arc::ptr_eq(&cancel, &state.cancel));
|
||||
assert!(!state.closed);
|
||||
assert!(Arc::ptr_eq(&original, &hub.get(JID).unwrap()));
|
||||
}
|
||||
assert_eq!(old_rx.try_recv().unwrap(), "one");
|
||||
original.lock().unwrap().transmit("two".into()).unwrap();
|
||||
assert_eq!(old_rx.try_recv().unwrap(), "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_revokes_hibernating_resume_and_survives_old_cleanup() {
|
||||
let hub = Hub::default();
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let old = hub.bind(JID, "old", tx, TTL).unwrap();
|
||||
let id = {
|
||||
let mut state = old.lock().unwrap();
|
||||
state.enable(true).unwrap();
|
||||
state.resume_id.clone().unwrap()
|
||||
};
|
||||
assert!(hub.disconnect("old", false).is_empty());
|
||||
assert!(old.lock().unwrap().expires.is_some());
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
let (new, replaced) = hub
|
||||
.bind_resolving(JID, "new", tx, TTL, ConflictPolicy::KickOld)
|
||||
.unwrap();
|
||||
assert!(Arc::ptr_eq(&old, &replaced.unwrap()));
|
||||
{
|
||||
let state = old.lock().unwrap();
|
||||
assert!(state.closed);
|
||||
assert!(state.resume_id.is_none());
|
||||
assert!(state.expires.is_none());
|
||||
}
|
||||
for graceful in [false, true] {
|
||||
assert!(hub.disconnect("old", graceful).is_empty());
|
||||
assert!(Arc::ptr_eq(&new, &hub.get(JID).unwrap()));
|
||||
}
|
||||
let (tx, _rx) = mpsc::channel(8);
|
||||
assert!(
|
||||
hub.resume("alice@example.test", &id, 0, "resurrect", tx)
|
||||
.is_err()
|
||||
);
|
||||
assert!(Arc::ptr_eq(&new, &hub.get(JID).unwrap()));
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent TLS/SASL2 Bind2 network regression. Usage: python3 tests/sasl2_bind.py BINARY."""
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
SASL2 = 'urn:xmpp:sasl:2'
|
||||
BIND2 = 'urn:xmpp:bind:0'
|
||||
SASL = 'urn:ietf:params:xml:ns:xmpp-sasl'
|
||||
STREAM = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, port, cert):
|
||||
self.sock = socket.create_connection(('127.0.0.1', port), timeout=5)
|
||||
self.buffer = b''
|
||||
self.send(STREAM)
|
||||
self.until(b'</stream:features>')
|
||||
self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
||||
assert b'proceed' in self.until(b'/>')
|
||||
self.sock = ssl.create_default_context(cafile=str(cert)).wrap_socket(self.sock, server_hostname='localhost')
|
||||
self.send(STREAM)
|
||||
features = self.until(b'</stream:features>')
|
||||
features = features[features.index(b'<stream:features'):]
|
||||
root = ET.fromstring(b"<root xmlns:stream='http://etherx.jabber.org/streams'>" + features + b'</root>')
|
||||
bind = root.find('.//{' + SASL2 + '}authentication/{' + SASL2 + '}inline/{' + BIND2 + '}bind')
|
||||
assert bind is not None, features
|
||||
advertised = {node.get('var') for node in bind.findall('{' + BIND2 + '}inline/{' + BIND2 + '}feature')}
|
||||
assert advertised == {'urn:xmpp:carbons:2', 'urn:xmpp:csi:0', 'urn:xmpp:sm:3'}, advertised
|
||||
|
||||
def send(self, xml):
|
||||
self.sock.sendall(xml.encode())
|
||||
|
||||
def until(self, marker):
|
||||
while marker not in self.buffer:
|
||||
chunk = self.sock.recv(65536)
|
||||
assert chunk, self.buffer
|
||||
self.buffer += chunk
|
||||
end = self.buffer.index(marker) + len(marker)
|
||||
result, self.buffer = self.buffer[:end], self.buffer[end:]
|
||||
return result
|
||||
|
||||
def auth(self, bind='', password='secret', good=True, user='alice', agent='private-device-id'):
|
||||
payload = base64.b64encode(('\0' + user + '\0' + password).encode()).decode()
|
||||
self.send(f"<authenticate xmlns='{SASL2}' mechanism='PLAIN'><initial-response>{payload}</initial-response><user-agent id='{agent}'/>{bind}</authenticate>")
|
||||
return ET.fromstring(self.until(b'</success>' if good else b'</failure>'))
|
||||
|
||||
def close(self):
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-bind2-') as tmp:
|
||||
root = Path(tmp)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'mam', 'carbons', 'csi_simple', 'smacks']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
|
||||
subprocess.run([binary, 'create-user', str(config), 'bob', '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
resources = []
|
||||
for _ in range(2):
|
||||
c = Client(port, root/'cert.pem')
|
||||
try:
|
||||
response = c.auth(f"<bind xmlns='{BIND2}'><tag>Phone&Tablet</tag><enable xmlns='urn:xmpp:carbons:2'/><inactive xmlns='urn:xmpp:csi:0'/><enable xmlns='urn:xmpp:sm:3' resume='true'/></bind>")
|
||||
jid = response.find('{' + SASL2 + '}authorization-identifier').text
|
||||
assert jid.startswith('alice@localhost/Phone&Tablet/'), jid
|
||||
assert 'private-device-id' not in jid
|
||||
resource = jid.split('/Phone&Tablet/')[1]
|
||||
assert uuid.UUID(resource).version == 4
|
||||
resources.append(resource)
|
||||
bound = response.find('{' + BIND2 + '}bound')
|
||||
assert bound is not None
|
||||
assert bound.find('{urn:xmpp:mam:2}metadata') is not None
|
||||
assert bound.find('{urn:xmpp:carbons:2}enabled') is not None
|
||||
assert bound.find('{urn:xmpp:sm:3}enabled').get('resume') == 'true'
|
||||
assert b'xmpp-bind' not in c.until(b'</stream:features>')
|
||||
# No legacy bind IQ: the session is already usable and SM enabled.
|
||||
c.send("<r xmlns='urn:xmpp:sm:3'/>")
|
||||
assert ET.fromstring(c.until(b'/>')).tag == '{urn:xmpp:sm:3}a'
|
||||
finally:
|
||||
c.close()
|
||||
assert resources[0] != resources[1]
|
||||
invalid = [f"<bind xmlns='{BIND2}'/><bind xmlns='{BIND2}'/>", f"<bind xmlns='{BIND2}'><tag>a</tag><tag>b</tag></bind>", f"<bind xmlns='{BIND2}'><tag>{'x'*987}</tag></bind>", f"<bind xmlns='{BIND2}'><tag>bad\ue000</tag></bind>", f"<bind xmlns='{BIND2}'><tag><nested/></tag></bind>", f"<bind xmlns='{BIND2}'><enable xmlns='urn:xmpp:sm:3' resume='yes'/></bind>"]
|
||||
for request in invalid:
|
||||
c = Client(port, root/'cert.pem')
|
||||
try:
|
||||
response = c.auth(request, good=False)
|
||||
assert response.find('{' + SASL + '}malformed-request') is not None, ET.tostring(response)
|
||||
# Malformed inline requests cannot leave a partially bound session.
|
||||
response = c.auth()
|
||||
assert response.find('{' + BIND2 + '}bound') is None
|
||||
assert response.find('{' + SASL2 + '}authorization-identifier').text == 'alice@localhost'
|
||||
assert b'xmpp-bind' in c.until(b'</stream:features>')
|
||||
finally:
|
||||
c.close()
|
||||
c = Client(port, root/'cert.pem')
|
||||
try:
|
||||
response = c.auth(f"<bind xmlns='{BIND2}'/>", password='wrong', good=False)
|
||||
assert response.find('{' + SASL + '}not-authorized') is not None
|
||||
assert response.find('{' + BIND2 + '}bound') is None
|
||||
response = c.auth(f"<bind xmlns='{BIND2}'/>")
|
||||
jid = response.find('{' + SASL2 + '}authorization-identifier').text
|
||||
assert uuid.UUID(jid.split('/', 1)[1]).version == 4
|
||||
finally:
|
||||
c.close()
|
||||
# Device replacement affects only this account and valid UUIDv4 ids.
|
||||
connections = []
|
||||
device = 'd4565fa7-4d72-4749-b3d3-740edbf87770'
|
||||
def bound_client(user, agent):
|
||||
client = Client(port, root/'cert.pem')
|
||||
connections.append(client)
|
||||
response = client.auth(f"<bind xmlns='{BIND2}'><enable xmlns='urn:xmpp:sm:3' resume='true'/></bind>", user=user, agent=agent)
|
||||
jid = response.find('{' + SASL2 + '}authorization-identifier').text
|
||||
assert agent not in jid
|
||||
client.until(b'</stream:features>')
|
||||
return client
|
||||
def ping(client):
|
||||
client.send("<r xmlns='urn:xmpp:sm:3'/>")
|
||||
assert ET.fromstring(client.until(b'/>')).tag == '{urn:xmpp:sm:3}a'
|
||||
try:
|
||||
other = bound_client('bob', device)
|
||||
old = bound_client('alice', device.upper())
|
||||
invalid_first = bound_client('alice', 'not-a-uuid')
|
||||
invalid_second = bound_client('alice', 'not-a-uuid')
|
||||
ping(invalid_first)
|
||||
fresh = bound_client('alice', device)
|
||||
old.sock.settimeout(2)
|
||||
old.until(b'</stream:stream>')
|
||||
assert old.sock.recv(1) == b'', 'old device stream remained open'
|
||||
for client in [fresh, other, invalid_first, invalid_second]:
|
||||
ping(client)
|
||||
finally:
|
||||
for client in connections:
|
||||
client.close()
|
||||
print('PASS: SASL2 Bind2 TLS flow, inline SM/CSI/Carbons, private random resource, malformed requests, authentication failure and fallback')
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
if server.returncode not in (0, -15):
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cached Conversations/monocles SASL2 inline requests over real STARTTLS.
|
||||
|
||||
Usage: python3 tests/sasl2_cached_inline.py BINARY [--expect-070-failure]
|
||||
Independent SCRAM-SHA-1 proof and server-signature validation; no client
|
||||
assertion that current features advertise the previously cached Bind2 feature.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from android_sasl_network import Connection, SASL, SASL2, b64
|
||||
|
||||
BIND2 = 'urn:xmpp:bind:0'
|
||||
SM = 'urn:xmpp:sm:3'
|
||||
CB = 'urn:xmpp:sasl-cb:0'
|
||||
CACHED_BIND = (f"<bind xmlns='{BIND2}'><tag>monocles chat</tag>"
|
||||
"<enable xmlns='urn:xmpp:carbons:2'/>"
|
||||
f"<enable xmlns='{SM}' resume='true'/></bind>")
|
||||
CACHED_RESUME = f"<resume xmlns='{SM}' previd='previous-server-stream' h='0'/>"
|
||||
|
||||
|
||||
def auth(client, mechanism, inline, password='secret', ssdp=False):
|
||||
parent = client.features.find('.//{' + SASL2 + '}authentication')
|
||||
mechanisms = [n.text for n in parent.findall('{' + SASL2 + '}mechanism')]
|
||||
assert mechanism in mechanisms, mechanisms
|
||||
if mechanism == 'PLAIN':
|
||||
initial = b64(('\0alice\0' + password).encode())
|
||||
else:
|
||||
gs2 = 'n,,'
|
||||
bare = 'n=alice,r=independent-cached-inline-nonce'
|
||||
initial = b64((gs2 + bare).encode())
|
||||
client.send(f"<authenticate xmlns='{SASL2}' mechanism='{mechanism}'>"
|
||||
f"<initial-response>{initial}</initial-response>"
|
||||
"<user-agent id='d4565fa7-4d72-4749-b3d3-740edbf87770'>"
|
||||
"<software>monocles chat</software></user-agent>"
|
||||
f"{inline}</authenticate>")
|
||||
result = client.stanza()
|
||||
if mechanism == 'PLAIN' or result.tag != '{' + SASL2 + '}challenge':
|
||||
return result
|
||||
first = base64.b64decode(result.text).decode()
|
||||
attrs = dict(item.split('=', 1) for item in first.split(','))
|
||||
if ssdp:
|
||||
bindings = [n.get('type') for n in client.features.findall('.//{' + CB + '}channel-binding')]
|
||||
protected = '\x1e'.join(sorted(mechanisms)) + '\x1f' + '\x1e'.join(sorted(bindings))
|
||||
assert attrs['h'] == b64(hashlib.sha1(protected.encode()).digest())
|
||||
else:
|
||||
assert 'h' not in attrs
|
||||
assert attrs['r'].startswith('independent-cached-inline-nonce')
|
||||
assert int(attrs['i']) >= 4096
|
||||
final = 'c=' + b64(gs2.encode()) + ',r=' + attrs['r']
|
||||
salted = hashlib.pbkdf2_hmac('sha1', password.encode(), base64.b64decode(attrs['s']), int(attrs['i']))
|
||||
mac = lambda key, data: hmac.new(key, data, hashlib.sha1).digest()
|
||||
key = mac(salted, b'Client Key')
|
||||
transcript = (bare + ',' + first + ',' + final).encode()
|
||||
signature = mac(hashlib.sha1(key).digest(), transcript)
|
||||
proof = bytes(a ^ b for a, b in zip(key, signature))
|
||||
client.send(f"<response xmlns='{SASL2}'>{b64((final + ',p=' + b64(proof)).encode())}</response>")
|
||||
result = client.stanza()
|
||||
if result.tag == '{' + SASL2 + '}success':
|
||||
expected = 'v=' + b64(mac(mac(salted, b'Server Key'), transcript))
|
||||
assert base64.b64decode(result.findtext('{' + SASL2 + '}additional-data')).decode() == expected
|
||||
return result
|
||||
|
||||
|
||||
def finish(client, result, bound):
|
||||
assert result.tag == '{' + SASL2 + '}success', ET.tostring(result)
|
||||
jid = result.findtext('{' + SASL2 + '}authorization-identifier')
|
||||
assert (result.find('{' + BIND2 + '}bound') is not None) == bound
|
||||
features = client.until(b'</stream:features>')
|
||||
if bound:
|
||||
assert jid.startswith('alice@localhost/monocles chat/'), jid
|
||||
assert b'xmpp-bind' not in features
|
||||
else:
|
||||
assert jid == 'alice@localhost', jid
|
||||
assert b'xmpp-bind' in features
|
||||
client.send("<iq type='set' id='bind'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>cached-fallback</resource></bind></iq>")
|
||||
reply = client.stanza()
|
||||
assert reply.get('type') == 'result', ET.tostring(reply)
|
||||
assert reply.findtext('.//{urn:ietf:params:xml:ns:xmpp-bind}jid') == 'alice@localhost/cached-fallback'
|
||||
client.send("<iq type='get' to='localhost' id='ping'><ping xmlns='urn:xmpp:ping'/></iq>")
|
||||
reply = client.stanza()
|
||||
assert reply.get('id') == 'ping' and reply.get('type') == 'result', ET.tostring(reply)
|
||||
|
||||
|
||||
def run(binary, old=False):
|
||||
cases = [('bind2-disabled', False, True), ('bind2-enabled', True, True), ('sm-disabled', False, False)]
|
||||
for label, bind2, sm in cases:
|
||||
for ssdp in (False, True):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-cached-inline-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
modules = ['tls', 'saslauth', 'sasl2', 'carbons', 'ping']
|
||||
modules += (['smacks'] if sm else []) + (['sasl2_bind2'] if bind2 else []) + (['sasl_ssdp'] if ssdp else [])
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': modules}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-1', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
inline = CACHED_BIND + (CACHED_RESUME if not sm else '')
|
||||
for mechanism in ('SCRAM-SHA-1', 'PLAIN'):
|
||||
for wrong in (False, True):
|
||||
c = Connection(port, root/'cert.pem', ssl.TLSVersion.TLSv1_3)
|
||||
try:
|
||||
advertised = c.features.find('.//{' + BIND2 + '}bind')
|
||||
assert (advertised is not None) == bind2
|
||||
result = auth(c, mechanism, inline, 'wrong' if wrong else 'secret', ssdp)
|
||||
if old:
|
||||
assert result.tag == '{' + SASL2 + '}failure', ET.tostring(result)
|
||||
assert result.find('{' + SASL + '}malformed-request') is not None
|
||||
print('PASS: reproduced 0.7.0 cached Bind2 rejection before SASL')
|
||||
return
|
||||
if wrong:
|
||||
assert result.tag == '{' + SASL2 + '}failure', ET.tostring(result)
|
||||
assert result.find('{' + SASL + '}not-authorized') is not None
|
||||
assert result.find('{' + BIND2 + '}bound') is None
|
||||
else:
|
||||
finish(c, result, bind2)
|
||||
finally:
|
||||
c.sock.close()
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
logs = log.read()
|
||||
if not bind2:
|
||||
assert 'inline-request-ignored' in logs and 'bind2-disabled' in logs, logs
|
||||
if not sm:
|
||||
assert 'sm-disabled' in logs, logs
|
||||
assert 'inline-request-malformed' not in logs, logs
|
||||
print(f'PASS: {label}, SSDP={ssdp}, SCRAM-SHA-1/PLAIN success and wrong-password rejection')
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(log.read())
|
||||
raise
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()), '--expect-070-failure' in sys.argv[2:])
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent TLS regression for SASL2 inline SM resume. Usage: script BINARY."""
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from sasl2_bind import Client as BindClient, SASL2, BIND2, SASL
|
||||
|
||||
SM = 'urn:xmpp:sm:3'
|
||||
|
||||
|
||||
class Client(BindClient):
|
||||
def until(self, marker):
|
||||
xml = super().until(marker)
|
||||
if b'<authentication' in xml:
|
||||
features = xml[xml.index(b'<stream:features'):]
|
||||
root = ET.fromstring(b"<root xmlns:stream='http://etherx.jabber.org/streams'>" + features + b'</root>')
|
||||
assert root.find('.//{' + SASL2 + '}inline/{' + SM + '}sm') is not None, features
|
||||
return xml
|
||||
|
||||
def ping(self, ident):
|
||||
self.send(f"<iq type='get' id='{ident}' to='localhost'><ping xmlns='urn:xmpp:ping'/></iq>")
|
||||
result = self.until(b'</iq>')
|
||||
assert b"type='result'" in result and f"id='{ident}'".encode() in result, result
|
||||
return result
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-sasl2-resume-') as tmp:
|
||||
root = Path(tmp)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'carbons', 'csi_simple', 'smacks', 'ping']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
for user in ['alice', 'bob']:
|
||||
subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'], input='secret\n', text=True, check=True, stdout=subprocess.DEVNULL)
|
||||
clients = []
|
||||
def connect():
|
||||
client = Client(port, root/'cert.pem')
|
||||
clients.append(client)
|
||||
return client
|
||||
def request(token, h=0):
|
||||
return f"<resume xmlns='{SM}' previd='{token}' h='{h}'/>"
|
||||
bind = f"<bind xmlns='{BIND2}'><tag>Phone</tag><enable xmlns='{SM}' resume='true'/></bind>"
|
||||
def assert_resume(response, token, jid):
|
||||
assert response.find(f'{{{SASL2}}}authorization-identifier').text == jid, ET.tostring(response)
|
||||
resumed = response.find(f'{{{SM}}}resumed')
|
||||
assert resumed is not None and resumed.get('previd') == token, ET.tostring(response)
|
||||
assert response.find(f'{{{BIND2}}}bound') is None, ET.tostring(response)
|
||||
assert response.find(f'{{{SM}}}failed') is None, ET.tostring(response)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server did not start')
|
||||
old = connect()
|
||||
response = old.auth(bind)
|
||||
jid = response.find(f'{{{SASL2}}}authorization-identifier').text
|
||||
token = response.find(f'{{{BIND2}}}bound/{{{SM}}}enabled').get('id')
|
||||
old.until(b'</stream:features>')
|
||||
old.ping('acknowledged')
|
||||
old.ping('pending')
|
||||
# Wrong credentials must not take over an active session.
|
||||
bad = connect()
|
||||
failure = bad.auth(request(token) + bind, password='wrong', good=False)
|
||||
assert failure.find(f'{{{SASL}}}not-authorized') is not None
|
||||
old.send(f"<r xmlns='{SM}'/>")
|
||||
assert ET.fromstring(old.until(b'/>')).tag == f'{{{SM}}}a'
|
||||
# Wrong owner, unknown id and impossible acknowledgement fail inside
|
||||
# success and retain optional Bind2 fallback. Failed requests leave
|
||||
# the original session and its pending queue untouched.
|
||||
for user, ident, h, fallback in [('bob', token, 0, True), ('alice', 'missing', 0, True), ('alice', token, 99, False)]:
|
||||
failed = connect()
|
||||
response = failed.auth(request(ident, h) + (bind if fallback else ''), user=user)
|
||||
assert response.find(f'{{{SM}}}failed') is not None, ET.tostring(response)
|
||||
assert response.find(f'{{{SM}}}resumed') is None
|
||||
assert (response.find(f'{{{BIND2}}}bound') is not None) == fallback
|
||||
authjid = response.find(f'{{{SASL2}}}authorization-identifier').text
|
||||
assert authjid.startswith(user + '@localhost/') if fallback else authjid == user + '@localhost'
|
||||
features = failed.until(b'</stream:features>')
|
||||
assert (b'xmpp-bind' in features) != fallback
|
||||
# Active transport takeover; Bind2 is ignored after successful resume.
|
||||
fresh = connect()
|
||||
response = fresh.auth(request(token, 1) + bind)
|
||||
assert_resume(response, token, jid)
|
||||
assert response.find(f'{{{SM}}}resumed').get('h') == '2'
|
||||
# auth() parses success first: replay can never precede it. Only the
|
||||
# second response remains unacknowledged and must be replayed once.
|
||||
replay = fresh.until(b'</iq>')
|
||||
assert b"id='pending'" in replay and b'acknowledged' not in replay, replay
|
||||
features = fresh.until(b'</stream:features>')
|
||||
assert b'xmpp-bind' not in features and b"<sm " not in features, features
|
||||
conflict = old.until(b'</stream:stream>')
|
||||
assert b'conflict' in conflict, conflict
|
||||
fresh.send(f"<a xmlns='{SM}' h='2'/><r xmlns='{SM}'/>")
|
||||
assert ET.fromstring(fresh.until(b'/>')).tag == f'{{{SM}}}a'
|
||||
# EOF suspension and replay-free second resume, without Bind2.
|
||||
fresh.close()
|
||||
again = connect()
|
||||
response = again.auth(request(token, 2))
|
||||
assert_resume(response, token, jid)
|
||||
assert b'xmpp-bind' not in again.until(b'</stream:features>')
|
||||
again.ping('after-resume')
|
||||
malformed = [request(token) * 2, f"<resume xmlns='{SM}' previd='{token}' h='bad'/>", f"<resume xmlns='{SM}' h='0'/>", f"<resume xmlns='{SM}' previd='{token}' h='0'><nested/></resume>"]
|
||||
for inline in malformed:
|
||||
client = connect()
|
||||
failure = client.auth(inline, good=False)
|
||||
assert failure.find(f'{{{SASL}}}malformed-request') is not None, ET.tostring(failure)
|
||||
response = client.auth()
|
||||
assert response.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost'
|
||||
client.until(b'</stream:features>')
|
||||
again.ping('survives-malformed')
|
||||
print('PASS: SASL2 inline SM active/EOF resume, authenticated owner/id/h checks, replay ordering, full JID, Bind2 fallback and malformed requests')
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
if server.returncode not in (0, -15):
|
||||
log.seek(0)
|
||||
print(log.read(), file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,37 @@
|
||||
use rust_xmpp::{
|
||||
auth,
|
||||
storage::{self, LuaValue},
|
||||
};
|
||||
#[test]
|
||||
fn disabled_legacy_and_unicode_accounts() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path();
|
||||
let mut disabled = LuaValue::table();
|
||||
disabled.set("disabled", LuaValue::Bool(true));
|
||||
storage::save(path, "example.com", Some("disabled"), "accounts", &disabled).unwrap();
|
||||
assert!(
|
||||
auth::load_scram(path, "example.com", "disabled", "SHA-256")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(!auth::verify(path, "example.com", "disabled", "password", "SHA-256").unwrap());
|
||||
let mut legacy = LuaValue::table();
|
||||
legacy.set("password", "pencil".into());
|
||||
legacy.set("custom-field", "preserve".into());
|
||||
storage::save(path, "example.com", Some("legacy"), "accounts", &legacy).unwrap();
|
||||
assert!(auth::verify(path, "example.com", "legacy", "pencil", "SHA-256").unwrap());
|
||||
auth::set_password(path, "example.com", "legacy", "changed", "SHA-256").unwrap();
|
||||
assert!(auth::verify(path, "example.com", "legacy", "changed", "SHA-256").unwrap());
|
||||
assert!(!auth::verify(path, "example.com", "legacy", "pencil", "SHA-256").unwrap());
|
||||
let data = storage::load(path, "example.com", Some("legacy"), "accounts")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(data.get("password").is_none());
|
||||
assert_eq!(
|
||||
data.get("custom-field").and_then(LuaValue::as_str),
|
||||
Some("preserve")
|
||||
);
|
||||
assert!(auth::set_password(path, "example.com", "missing", "password", "SHA-256").is_err());
|
||||
auth::create(path, "example.com", "пользователь", "пароль", "SHA-256").unwrap();
|
||||
assert!(auth::verify(path, "example.com", "пользователь", "пароль", "SHA-256").unwrap());
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent stdlib SCRAM client; --spawn creates an isolated TLS test server.
|
||||
Tests SHA-1/SHA-256 with SASL1, SASL2, SSDP and server signature validation.
|
||||
"""
|
||||
import argparse,base64,hashlib,hmac,secrets,socket,ssl,xml.etree.ElementTree as ET
|
||||
import json,subprocess,tempfile,signal,time,sys
|
||||
from pathlib import Path
|
||||
p=argparse.ArgumentParser();p.add_argument('--port',type=int,default=55222);p.add_argument('--cafile',default=str(Path(__file__).resolve().parents[1]/'demo/cert.pem'));p.add_argument('--user',default='alice');p.add_argument('--password',default='alice-test-only');p.add_argument('--spawn',action='store_true');p.add_argument('--hash',choices=['sha1','sha256'],default='sha256');p.add_argument('--require-ssdp',action='store_true');p.add_argument('--test-push',action='store_true');p.add_argument('--binary',default=str(Path(__file__).resolve().parents[1]/'target/release/rust-xmpp'));args=p.parse_args()
|
||||
mechanism='SCRAM-SHA-1' if args.hash=='sha1' else 'SCRAM-SHA-256'
|
||||
if args.spawn:
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-sasl-') as tmp:
|
||||
root=Path(tmp);probe=socket.socket();probe.bind(('127.0.0.1',0));port=probe.getsockname()[1];probe.close()
|
||||
subprocess.run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',str(root/'key.pem'),'-out',str(root/'cert.pem'),'-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
config={'global':{'data_path':str(root/'data'),'interfaces':['127.0.0.1'],'c2s_ports':[port],'s2s_ports':[],'http_ports':[],'https_ports':[],'modules_enabled':['tls','saslauth','sasl2','sasl_ssdp','cloud_notify','smacks']},'hosts':{'localhost':{'options':{'authentication':'internal_hashed','password_hash':'SHA-1' if args.hash=='sha1' else 'SHA-256','ssl':{'key':str(root/'key.pem'),'certificate':str(root/'cert.pem')}}}}}
|
||||
(root/'config.json').write_text(json.dumps(config))
|
||||
subprocess.run([args.binary,'create-user',str(root/'config.json'),args.user,'--password-stdin'],input=args.password+'\n',text=True,check=True,stdout=subprocess.DEVNULL)
|
||||
server=subprocess.Popen([args.binary,'serve',str(root/'config.json'),'--experimental'],stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if server.poll() is not None:raise RuntimeError(server.stderr.read().decode())
|
||||
try:probe=socket.create_connection(('127.0.0.1',port),timeout=.1);probe.close();break
|
||||
except OSError:time.sleep(.05)
|
||||
else:raise RuntimeError('SASL server did not start')
|
||||
subprocess.run([sys.executable,__file__,'--port',str(port),'--cafile',str(root/'cert.pem'),'--user',args.user,'--password',args.password,'--hash',args.hash,'--require-ssdp','--test-push'],check=True)
|
||||
stored=next((root/'data').glob('*/cloud_notify/*.dat')).read_text()
|
||||
assert 'test-stable-device' in stored and 'client_id' in stored
|
||||
assert 'resource' not in stored,stored
|
||||
finally:
|
||||
server.send_signal(signal.SIGINT)
|
||||
try:server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:server.kill();server.wait()
|
||||
sys.exit(0)
|
||||
b64=lambda x:base64.b64encode(x).decode()
|
||||
stream="<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>"
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.s=socket.create_connection(('127.0.0.1',args.port),timeout=5);self.buf=b''
|
||||
self.send(stream);self.until(b'</stream:features>')
|
||||
self.send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");assert b'proceed' in self.until(b'/>')
|
||||
self.s=ssl.create_default_context(cafile=args.cafile).wrap_socket(self.s,server_hostname='localhost')
|
||||
self.send(stream);features=self.until(b'</stream:features>');assert mechanism.encode() in features
|
||||
part=features[features.index(b'<stream:features'):];tree=ET.fromstring(b"<root xmlns:stream='http://etherx.jabber.org/streams'>"+part+b'</root>')
|
||||
self.mechs=sorted({n.text for n in tree.iter() if n.tag=='{urn:ietf:params:xml:ns:xmpp-sasl}mechanism'})
|
||||
self.bindings=sorted({n.get('type') for n in tree.iter() if n.tag=='{urn:xmpp:sasl-cb:0}channel-binding'})
|
||||
def send(self,data):self.s.sendall(data.encode())
|
||||
def until(self,mark):
|
||||
while mark not in self.buf:
|
||||
chunk=self.s.recv(65536)
|
||||
if not chunk:raise RuntimeError('Unexpected 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 auth(self,sasl2=False,bad=False):
|
||||
ns='urn:xmpp:sasl:2' if sasl2 else 'urn:ietf:params:xml:ns:xmpp-sasl'
|
||||
nonce=secrets.token_hex(18);bare=f'n={args.user},r={nonce}';initial=b64(('n,,'+bare).encode())
|
||||
if sasl2:self.send(f"<authenticate xmlns='{ns}' mechanism='{mechanism}'><initial-response>{initial}</initial-response><user-agent id='test-stable-device'/></authenticate>")
|
||||
else:self.send(f"<auth xmlns='{ns}' mechanism='{mechanism}'>{initial}</auth>")
|
||||
node=ET.fromstring(self.until(b'</challenge>'));server_first=base64.b64decode(node.text).decode();attrs=dict(x.split('=',1) for x in server_first.split(','))
|
||||
assert attrs['r'].startswith(nonce) and len(attrs['r'])>len(nonce)
|
||||
if args.require_ssdp:assert 'h' in attrs,'SSDP challenge missing'
|
||||
if 'h' in attrs:
|
||||
source='\x1e'.join(self.mechs)+('\x1f'+'\x1e'.join(self.bindings) if self.bindings else '')
|
||||
assert attrs['h']==b64(hashlib.new(args.hash,source.encode()).digest()),'SSDP mechanism hash mismatch'
|
||||
final=f"c=biws,r={attrs['r']}";authmsg=f'{bare},{server_first},{final}'.encode()
|
||||
salted=hashlib.pbkdf2_hmac(args.hash,('wrong-password' if bad else args.password).encode(),base64.b64decode(attrs['s']),int(attrs['i']))
|
||||
mac=lambda k,s:hmac.digest(k,s,args.hash)
|
||||
client_key=mac(salted,b'Client Key');signature=mac(hashlib.new(args.hash,client_key).digest(),authmsg)
|
||||
proof=bytes(a^b for a,b in zip(client_key,signature));final+=',p='+b64(proof)
|
||||
self.send(f"<response xmlns='{ns}'>{b64(final.encode())}</response>")
|
||||
if bad:
|
||||
failure=ET.fromstring(self.until(b'</failure>'))
|
||||
assert failure.tag=='{'+ns+'}failure'
|
||||
assert failure.find('{urn:ietf:params:xml:ns:xmpp-sasl}not-authorized') is not None, ET.tostring(failure)
|
||||
return
|
||||
result=ET.fromstring(self.until(b'</success>'))
|
||||
data=result.find('{urn:xmpp:sasl:2}additional-data').text if sasl2 else result.text
|
||||
expected='v='+b64(mac(mac(salted,b'Server Key'),authmsg));assert base64.b64decode(data).decode()==expected
|
||||
if sasl2:assert result.find('{urn:xmpp:sasl:2}authorization-identifier').text==args.user+'@localhost'
|
||||
else:self.send(stream)
|
||||
assert b'xmpp-bind' in self.until(b'</stream:features>')
|
||||
if sasl2 and args.test_push:
|
||||
self.send("<iq type='set' id='bind'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>mobile</resource></bind></iq>")
|
||||
assert b"type='result'" in self.until(b'</iq>')
|
||||
self.send("<iq type='set' id='push'><enable xmlns='urn:xmpp:push:0' jid='push.localhost' node='phone'/></iq>")
|
||||
assert b"type='result'" in self.until(b'</iq>')
|
||||
self.send("<enable xmlns='urn:xmpp:sm:3' resume='true'/>")
|
||||
enabled=ET.fromstring(self.until(b'/>'));assert enabled.get('max')=='259200',ET.tostring(enabled)
|
||||
|
||||
def close(self):self.s.close()
|
||||
for sasl2,bad in [(False,False),(True,False),(False,True),(True,True)]:
|
||||
c=Client()
|
||||
try:c.auth(sasl2,bad)
|
||||
finally:c.close()
|
||||
print(f'PASS: independent {mechanism} over TLS, SASL1/SASL2, SSDP verification, server signatures and wrong-password rejection')
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real STARTTLS regression for SASL identity preparation and exchange retries.
|
||||
|
||||
Usage: python3 tests/sasl_retry_network.py BINARY
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from android_sasl_network import Connection, OPEN, SASL, SASL2, b64
|
||||
|
||||
BIND = 'urn:ietf:params:xml:ns:xmpp-bind'
|
||||
|
||||
|
||||
def authenticate(connection, sasl2, mechanism, payload):
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
encoded = b64(payload.encode())
|
||||
if sasl2:
|
||||
connection.send(f"<authenticate xmlns='{ns}' mechanism='{mechanism}'><initial-response>{encoded}</initial-response></authenticate>")
|
||||
else:
|
||||
connection.send(f"<auth xmlns='{ns}' mechanism='{mechanism}'>{encoded}</auth>")
|
||||
|
||||
|
||||
def plain(connection, sasl2, username='alice', authz=''):
|
||||
authenticate(connection, sasl2, 'PLAIN', f'{authz}\0{username}\0secret')
|
||||
|
||||
|
||||
def expect_failure(connection, sasl2, condition):
|
||||
result = connection.stanza()
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
assert result.tag == '{' + ns + '}failure', ET.tostring(result)
|
||||
assert result.find('{' + SASL + '}' + condition) is not None, ET.tostring(result)
|
||||
|
||||
|
||||
def expect_canonical_bind(connection, sasl2):
|
||||
result = connection.stanza()
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
assert result.tag == '{' + ns + '}success', ET.tostring(result)
|
||||
if sasl2:
|
||||
assert result.findtext('{' + SASL2 + '}authorization-identifier') == 'alice@localhost', ET.tostring(result)
|
||||
else:
|
||||
connection.send(OPEN)
|
||||
connection.until(b'</stream:features>')
|
||||
connection.send(f"<iq xmlns='jabber:client' type='set' id='bind'><bind xmlns='{BIND}'><resource>retry-test</resource></bind></iq>")
|
||||
result = connection.stanza()
|
||||
assert result.get('type') == 'result' and result.get('id') == 'bind', ET.tostring(result)
|
||||
assert result.findtext('.//{' + BIND + '}jid') == 'alice@localhost/retry-test', ET.tostring(result)
|
||||
|
||||
|
||||
def start_scram(connection, sasl2):
|
||||
authenticate(connection, sasl2, 'SCRAM-SHA-256', 'n,,n=alice,r=retry-regression-nonce')
|
||||
result = connection.stanza()
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
assert result.tag == '{' + ns + '}challenge', ET.tostring(result)
|
||||
|
||||
|
||||
def check_case(connection, sasl2, case):
|
||||
if case in ('uppercase', 'fullwidth'):
|
||||
user = 'ALICE' if case == 'uppercase' else 'Alice'
|
||||
plain(connection, sasl2, user, user + '@localhost')
|
||||
elif case == 'invalid-authz':
|
||||
plain(connection, sasl2, 'ALICE', 'bob@localhost')
|
||||
expect_failure(connection, sasl2, 'not-authorized')
|
||||
plain(connection, sasl2)
|
||||
elif case == 'abort':
|
||||
start_scram(connection, sasl2)
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
connection.send(f"<abort xmlns='{ns}'/>")
|
||||
expect_failure(connection, sasl2, 'aborted')
|
||||
plain(connection, sasl2)
|
||||
elif case == 'restart':
|
||||
start_scram(connection, sasl2)
|
||||
plain(connection, sasl2, 'ALICE')
|
||||
else:
|
||||
raise AssertionError(case)
|
||||
expect_canonical_bind(connection, sasl2)
|
||||
|
||||
|
||||
def run(binary):
|
||||
cases = ('uppercase', 'fullwidth', 'invalid-authz', 'abort', 'restart')
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-sasl-retry-') as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
port = probe.getsockname()[1]
|
||||
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 'http_ports': [], 'https_ports': [], 's2s_ports': [], 'modules_enabled': ['tls', 'saslauth', 'sasl2']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}}
|
||||
config = root/'config.json'
|
||||
config.write_text(json.dumps(cfg))
|
||||
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
||||
active = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert process.poll() is None, 'server stopped'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
for version in (ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3):
|
||||
for sasl2 in (False, True):
|
||||
for case in cases:
|
||||
active = (version.name, 'SASL2' if sasl2 else 'SASL1', case)
|
||||
connection = Connection(port, root/'cert.pem', version)
|
||||
try:
|
||||
check_case(connection, sasl2, case)
|
||||
finally:
|
||||
connection.sock.close()
|
||||
except BaseException:
|
||||
print('FAILED case:', active)
|
||||
log.flush(); log.seek(0); print(log.read())
|
||||
raise
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill(); process.wait()
|
||||
print('PASS: 20 TLS1.2/1.3 SASL1/2 cases: prepared PLAIN identities and canonical binds, invalid authz rejection, SCRAM abort/retry and active exchange restart')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1]).resolve()))
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real WSS SCRAM regression: SASL1/SASL2, GS2 y, SSDP and session use.
|
||||
|
||||
Requires websockets and openssl. Runs an isolated loopback-only server.
|
||||
Usage: python3 tests/sasl_web_network.py [--binary target/release/rust-xmpp]
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import websockets
|
||||
|
||||
CLIENT = 'jabber:client'
|
||||
SASL = 'urn:ietf:params:xml:ns:xmpp-sasl'
|
||||
SASL2 = 'urn:xmpp:sasl:2'
|
||||
CB = 'urn:xmpp:sasl-cb:0'
|
||||
BIND = 'urn:ietf:params:xml:ns:xmpp-bind'
|
||||
FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing'
|
||||
PASSWORD = 'integration-password'
|
||||
OPEN = f'<open xmlns="{FRAMING}" to="localhost" version="1.0"/>'
|
||||
|
||||
|
||||
def b64(data):
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def port():
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
async def receive(ws, predicate):
|
||||
async with asyncio.timeout(8):
|
||||
while True:
|
||||
node = ET.fromstring(await ws.recv())
|
||||
assert node.tag not in (f'{{{SASL}}}failure', f'{{{SASL2}}}failure'), ET.tostring(node)
|
||||
if predicate(node):
|
||||
return node
|
||||
|
||||
|
||||
async def authenticate(ws, sasl2):
|
||||
await ws.send(OPEN)
|
||||
features = await receive(ws, lambda n: n.tag.endswith('}features'))
|
||||
assert features.find(f'.//{{{CB}}}sasl-channel-binding') is None, ET.tostring(features)
|
||||
assert features.find(f'.//{{{CB}}}channel-binding') is None, ET.tostring(features)
|
||||
sasl1_mechs = sorted(n.text for n in features.findall(f'{{{SASL}}}mechanisms/{{{SASL}}}mechanism'))
|
||||
sasl2_mechs = sorted(n.text for n in features.findall(f'{{{SASL2}}}authentication/{{{SASL2}}}mechanism'))
|
||||
for mechs in (sasl1_mechs, sasl2_mechs):
|
||||
assert mechs == ['PLAIN', 'SCRAM-SHA-256'], mechs
|
||||
assert not any(m.endswith('-PLUS') for m in mechs), mechs
|
||||
mechs = sasl2_mechs if sasl2 else sasl1_mechs
|
||||
ns = SASL2 if sasl2 else SASL
|
||||
nonce = secrets.token_hex(18)
|
||||
bare = f'n=alice,r={nonce}'
|
||||
# Client supports CB but WSS cannot expose the TLS binding to XMPP.
|
||||
gs2 = b'y,,'
|
||||
initial = b64(gs2 + bare.encode())
|
||||
if sasl2:
|
||||
await ws.send(f'<authenticate xmlns="{ns}" mechanism="SCRAM-SHA-256"><initial-response>{initial}</initial-response></authenticate>')
|
||||
else:
|
||||
await ws.send(f'<auth xmlns="{ns}" mechanism="SCRAM-SHA-256">{initial}</auth>')
|
||||
challenge = await receive(ws, lambda n: n.tag == f'{{{ns}}}challenge')
|
||||
server_first = base64.b64decode(challenge.text, validate=True).decode()
|
||||
attrs = dict(item.split('=', 1) for item in server_first.split(','))
|
||||
assert attrs['r'].startswith(nonce) and len(attrs['r']) > len(nonce), server_first
|
||||
# No CB advertisement means the SSDP input ends after the mechanism list;
|
||||
# appending even an empty CB separator (0x1f) produces a different hash.
|
||||
ssdp_source = '\x1e'.join(mechs).encode()
|
||||
expected_ssdp = b64(hashlib.sha256(ssdp_source).digest())
|
||||
assert attrs.get('h') == expected_ssdp, (attrs.get('h'), expected_ssdp)
|
||||
assert attrs['h'] != b64(hashlib.sha256(ssdp_source + b'\x1f').digest())
|
||||
final_bare = f"c={b64(gs2)},r={attrs['r']}"
|
||||
auth_message = f'{bare},{server_first},{final_bare}'.encode()
|
||||
salted = hashlib.pbkdf2_hmac('sha256', PASSWORD.encode(), base64.b64decode(attrs['s'], validate=True), int(attrs['i']))
|
||||
mac = lambda key, data: hmac.digest(key, data, 'sha256')
|
||||
client_key = mac(salted, b'Client Key')
|
||||
signature = mac(hashlib.sha256(client_key).digest(), auth_message)
|
||||
proof = bytes(a ^ b for a, b in zip(client_key, signature))
|
||||
final = final_bare + ',p=' + b64(proof)
|
||||
await ws.send(f'<response xmlns="{ns}">{b64(final.encode())}</response>')
|
||||
success = await receive(ws, lambda n: n.tag == f'{{{ns}}}success')
|
||||
data = success.find(f'{{{SASL2}}}additional-data').text if sasl2 else success.text
|
||||
expected_signature = 'v=' + b64(mac(mac(salted, b'Server Key'), auth_message))
|
||||
assert base64.b64decode(data, validate=True).decode() == expected_signature
|
||||
if sasl2:
|
||||
assert success.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost'
|
||||
else:
|
||||
await ws.send(OPEN)
|
||||
await receive(ws, lambda n: n.tag.endswith('}features'))
|
||||
resource = 'sasl2-wss' if sasl2 else 'sasl1-wss'
|
||||
await ws.send(f'<iq xmlns="{CLIENT}" type="set" id="bind"><bind xmlns="{BIND}"><resource>{resource}</resource></bind></iq>')
|
||||
bound = await receive(ws, lambda n: n.get('id') == 'bind')
|
||||
assert bound.get('type') == 'result', ET.tostring(bound)
|
||||
assert bound.find(f'.//{{{BIND}}}jid').text == f'alice@localhost/{resource}'
|
||||
await ws.send(f'<iq xmlns="{CLIENT}" type="get" id="ping" to="localhost"><ping xmlns="urn:xmpp:ping"/></iq>')
|
||||
pong = await receive(ws, lambda n: n.get('id') == 'ping')
|
||||
assert pong.get('type') == 'result', ET.tostring(pong)
|
||||
await ws.send(f'<close xmlns="{FRAMING}"/>')
|
||||
await receive(ws, lambda n: n.tag == f'{{{FRAMING}}}close')
|
||||
|
||||
|
||||
async def exercise(https_port, context):
|
||||
for sasl2 in (False, True):
|
||||
async with websockets.connect(f'wss://localhost:{https_port}/xmpp-websocket', ssl=context,
|
||||
subprotocols=['xmpp'], open_timeout=8) as ws:
|
||||
await authenticate(ws, sasl2)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp'))
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-sasl-web-') as temporary:
|
||||
root = Path(temporary)
|
||||
cert, key = root / 'cert.pem', root / 'key.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256',
|
||||
'-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost',
|
||||
'-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(key), '-out', str(cert)],
|
||||
check=True, capture_output=True)
|
||||
tcp_port, https_port = port(), port()
|
||||
config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
|
||||
'https_interfaces': ['127.0.0.1'], 'c2s_ports': [tcp_port], 's2s_ports': [],
|
||||
'http_ports': [], 'https_ports': [https_port], 'consider_websocket_secure': True,
|
||||
'modules_enabled': ['tls', 'websocket', 'sasl2', 'saslauth', 'sasl_ssdp', 'ping'],
|
||||
'ssl': {'certificate': str(cert), 'key': str(key)}},
|
||||
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}}}}
|
||||
path = root / 'config.json'
|
||||
path.write_text(json.dumps(config))
|
||||
subprocess.run([args.binary, 'create-user', str(path), 'alice', '--password-stdin'],
|
||||
input=PASSWORD + '\n', text=True, check=True, capture_output=True)
|
||||
with (root / 'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', tcp_port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server startup timed out')
|
||||
asyncio.run(exercise(https_port, ssl.create_default_context(cafile=str(cert))))
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print('SERVER LOG:\n' + log.read())
|
||||
raise
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
print('PASS: WSS SCRAM-SHA-256 SASL1/SASL2 with GS2 y, absent CB/PLUS, SSDP without CB separator, server signatures, bind and ping')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,605 @@
|
||||
use rust_xmpp::{
|
||||
account_modules as a,
|
||||
storage::{self, LuaValue as V},
|
||||
};
|
||||
fn iq(root: &std::path::Path, s: &str) -> rust_xmpp::protocol::ModuleResult {
|
||||
let d = roxmltree::Document::parse(s).unwrap();
|
||||
a::handle(
|
||||
root,
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
d.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn private_and_vcard_authorization() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='1' to='bob@example.test'><query xmlns='jabber:iq:private'><x xmlns='test'>secret</x></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("forbidden"));
|
||||
assert!(!t.path().join("example%2etest").exists());
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='1'><query xmlns='jabber:iq:private'><x xmlns='test'>secret</x></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("result"));
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='get' id='2'><query xmlns='jabber:iq:private'><x xmlns='test'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("secret"));
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='1' to='bob@example.test'><vCard xmlns='vcard-temp'><FN>Alice</FN></vCard></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("forbidden"));
|
||||
}
|
||||
#[test]
|
||||
fn roster_preserves_subscription_and_metadata() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let v=storage::parse(b"return { [false]={version=9;pending={['foo@example.test']=true}};['bob@example.test']={subscription='both';ask='subscribe';groups={Old=true}}}").unwrap();
|
||||
storage::save(t.path(), "example.test", Some("alice"), "roster", &v).unwrap();
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='1'><query xmlns='jabber:iq:roster'><item jid='bob@example.test' subscription='none' name='Bob'><group>New</group></item></query></iq>",
|
||||
);
|
||||
assert_eq!(r.deliveries.len(), 1);
|
||||
let v = storage::load(t.path(), "example.test", Some("alice"), "roster")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
v.get("bob@example.test")
|
||||
.unwrap()
|
||||
.get("subscription")
|
||||
.and_then(V::as_str),
|
||||
Some("both")
|
||||
);
|
||||
assert_eq!(
|
||||
v.get_key(&V::Bool(false)).unwrap().get("version"),
|
||||
Some(&V::Integer(10))
|
||||
);
|
||||
assert!(v.get_key(&V::Bool(false)).unwrap().get("pending").is_some());
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='get' id='1'><query xmlns='jabber:iq:roster' ver='10'/></iq>",
|
||||
);
|
||||
assert!(!r.replies[0].contains("<query"));
|
||||
}
|
||||
#[test]
|
||||
fn blocklist_enforcement_api() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='1'><block xmlns='urn:xmpp:blocking'><item jid='bob@example.test'/></block></iq>",
|
||||
);
|
||||
assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/device").unwrap());
|
||||
assert!(!a::is_blocked(t.path(), "example.test", "alice", "carol@example.test").unwrap());
|
||||
iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='2'><unblock xmlns='urn:xmpp:blocking'/></iq>",
|
||||
);
|
||||
assert!(!a::is_blocked(t.path(), "example.test", "alice", "bob@example.test").unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn subscription_handshake_and_cancel() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
for u in ["alice", "bob"] {
|
||||
let mut v = V::table();
|
||||
v.set("test", V::Bool(true));
|
||||
storage::save(t.path(), "example.test", Some(u), "accounts", &v).unwrap();
|
||||
}
|
||||
let p =
|
||||
roxmltree::Document::parse("<presence type='subscribe' to='bob@example.test'/>").unwrap();
|
||||
a::subscription(
|
||||
t.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
p.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
a::pending_subscriptions(t.path(), "example.test", "bob")
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let p = roxmltree::Document::parse("<presence type='subscribed' to='alice@example.test'/>")
|
||||
.unwrap();
|
||||
a::subscription(
|
||||
t.path(),
|
||||
"example.test",
|
||||
"bob@example.test/r",
|
||||
p.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
a::subscriptions(t.path(), "example.test", "alice").unwrap(),
|
||||
vec!["bob@example.test"]
|
||||
);
|
||||
assert_eq!(
|
||||
a::subscribers(t.path(), "example.test", "bob").unwrap(),
|
||||
vec!["alice@example.test"]
|
||||
);
|
||||
assert!(
|
||||
a::pending_subscriptions(t.path(), "example.test", "bob")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let p =
|
||||
roxmltree::Document::parse("<presence type='unsubscribe' to='bob@example.test'/>").unwrap();
|
||||
a::subscription(
|
||||
t.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
p.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
a::subscriptions(t.path(), "example.test", "alice")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
a::subscribers(t.path(), "example.test", "bob")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn reads_timestamp_blocklist_and_keeps_metadata() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let v =
|
||||
storage::parse(b"return {[false]={created=42};['bob@example.test']=1729000000}").unwrap();
|
||||
storage::save(t.path(), "example.test", Some("alice"), "blocklist", &v).unwrap();
|
||||
assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/r").unwrap());
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='get' id='1'><blocklist xmlns='urn:xmpp:blocking'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("bob@example.test"));
|
||||
iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='2'><unblock xmlns='urn:xmpp:blocking'/></iq>",
|
||||
);
|
||||
let v = storage::load(t.path(), "example.test", Some("alice"), "blocklist")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
v.get_key(&V::Bool(false)).unwrap().get("created"),
|
||||
Some(&V::Integer(42))
|
||||
);
|
||||
}
|
||||
fn legacy(root: &std::path::Path, jid: &str, s: &str) -> rust_xmpp::protocol::ModuleResult {
|
||||
let user = jid.split('@').next().unwrap();
|
||||
if storage::load(root, "example.test", Some(user), "accounts")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
{
|
||||
let mut v = V::table();
|
||||
v.set("test", V::Bool(true));
|
||||
storage::save(root, "example.test", Some(user), "accounts", &v).unwrap();
|
||||
}
|
||||
let d = roxmltree::Document::parse(s).unwrap();
|
||||
a::handle_legacy(root, "example.test", jid, d.root_element())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn vcard_pep_conversion_and_avatar() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='set' id='v'><vCard xmlns='vcard-temp'><FN>Alice</FN><N><FAMILY>Test</FAMILY></N><EMAIL><USERID>a@example.test</USERID><HOME/></EMAIL><PHOTO><TYPE>image/png</TYPE><BINVAL>aGVsbG8=</BINVAL></PHOTO></vCard></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("result"));
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"bob@example.test/r",
|
||||
"<iq type='get' id='v' to='alice@example.test'><vCard xmlns='vcard-temp'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("Alice"));
|
||||
assert!(r.replies[0].contains("aGVsbG8="));
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='get' id='v'><vcard xmlns='urn:ietf:params:xml:ns:vcard-4.0'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("surname"));
|
||||
let p = roxmltree::Document::parse("<presence xmlns='jabber:client'/>").unwrap();
|
||||
let s = a::avatar_presence(
|
||||
t.path(),
|
||||
"example.test",
|
||||
"alice@example.test/r",
|
||||
p.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.contains("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"));
|
||||
}
|
||||
#[test]
|
||||
fn bookmarks_private_pep_bidirectional_and_private() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='set' id='b'><query xmlns='jabber:iq:private'><storage xmlns='storage:bookmarks'><conference jid='room@conference.example.test' name='Room' autojoin='true'><nick>Alice</nick><password>secret</password></conference></storage></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("result"));
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='get' id='b'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='storage:bookmarks'/></pubsub></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("secret"));
|
||||
let d=roxmltree::Document::parse("<iq type='get' id='x' to='alice@example.test'><pubsub xmlns='http://jabber.org/protocol/pubsub'><items node='urn:xmpp:bookmarks:1'/></pubsub></iq>").unwrap();
|
||||
let r = rust_xmpp::pubsub::handle(
|
||||
t.path(),
|
||||
"example.test",
|
||||
"bob@example.test/r",
|
||||
d.root_element(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(r.replies[0].contains("forbidden"));
|
||||
legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='set' id='b'><query xmlns='jabber:iq:private'><storage xmlns='storage:bookmarks'/></query></iq>",
|
||||
);
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='get' id='b'><query xmlns='jabber:iq:private'><storage xmlns='storage:bookmarks'/></query></iq>",
|
||||
);
|
||||
assert!(!r.replies[0].contains("room@"));
|
||||
}
|
||||
#[test]
|
||||
fn inbound_subscription_requires_pending_to_grant_to() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut v = V::table();
|
||||
v.set("test", V::Bool(true));
|
||||
storage::save(t.path(), "example.test", Some("alice"), "accounts", &v).unwrap();
|
||||
let d = roxmltree::Document::parse("<presence type='subscribed' to='alice@example.test'/>")
|
||||
.unwrap();
|
||||
a::inbound_subscription(
|
||||
t.path(),
|
||||
"example.test",
|
||||
"evil@other.test",
|
||||
d.root_element(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
a::subscriptions(t.path(), "example.test", "alice")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_migration_clears_source_after_success() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut account = V::table();
|
||||
account.set("test", V::Bool(true));
|
||||
storage::save(
|
||||
t.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"accounts",
|
||||
&account,
|
||||
)
|
||||
.unwrap();
|
||||
let d =
|
||||
roxmltree::Document::parse("<vCard xmlns='vcard-temp'><FN>Old Alice</FN></vCard>").unwrap();
|
||||
storage::save(
|
||||
t.path(),
|
||||
"example.test",
|
||||
Some("alice"),
|
||||
"vcard",
|
||||
&storage::stanza_value(d.root_element()),
|
||||
)
|
||||
.unwrap();
|
||||
let d=roxmltree::Document::parse("<storage xmlns='storage:bookmarks'><conference jid='room@conference.example.test'/></storage>").unwrap();
|
||||
let mut private = V::table();
|
||||
private.set(
|
||||
"storage:storage:bookmarks",
|
||||
storage::stanza_value(d.root_element()),
|
||||
);
|
||||
private.set("unrelated", "keep".into());
|
||||
storage::save(t.path(), "example.test", Some("alice"), "private", &private).unwrap();
|
||||
a::legacy_on_bind(t.path(), "example.test", "alice@example.test/r").unwrap();
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "vcard")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let v = storage::load(t.path(), "example.test", Some("alice"), "private")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(v.get("storage:storage:bookmarks").is_none());
|
||||
assert_eq!(v.get("unrelated").and_then(V::as_str), Some("keep"));
|
||||
a::legacy_on_bind(t.path(), "example.test", "alice@example.test/r").unwrap();
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='get' id='v'><vCard xmlns='vcard-temp'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("Old Alice"));
|
||||
}
|
||||
#[test]
|
||||
fn domain_roster_and_block_jid_normalization() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='r'><query xmlns='jabber:iq:roster'><item jid='Transport.EXAMPLE.test' name='Transport'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("result"));
|
||||
let d = storage::load(t.path(), "example.test", Some("alice"), "roster")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(d.get("transport.example.test").is_some());
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='b'><block xmlns='urn:xmpp:blocking'><item jid='BOB@EXAMPLE.TEST/Phone'/></block></iq>",
|
||||
);
|
||||
assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/Phone").unwrap());
|
||||
assert!(r.deliveries[0].1.contains("bob@example.test/Phone"));
|
||||
}
|
||||
#[test]
|
||||
fn purge_account_exact_files_preserves_others_and_extras() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let root = t.path();
|
||||
for store in ["accounts", "roster", "pep_aabbcc", "custom_store"] {
|
||||
for user in ["alice", "bob"] {
|
||||
for ext in ["dat", "list", "lidx", "rappend"] {
|
||||
let p = storage::path(root, "example.test", Some(user), store).with_extension(ext);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, b"fixture").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra =
|
||||
storage::path(root, "example.test", Some("alice"), "custom_store").with_extension("backup");
|
||||
std::fs::write(&extra, b"keep").unwrap();
|
||||
let hostwide = storage::path(root, "example.test", None, "hostwide");
|
||||
std::fs::write(&hostwide, b"keep").unwrap();
|
||||
a::remove_account(root, "example.test", "alice").unwrap();
|
||||
for store in ["accounts", "roster", "pep_aabbcc", "custom_store"] {
|
||||
for ext in ["dat", "list", "lidx", "rappend"] {
|
||||
assert!(
|
||||
!storage::path(root, "example.test", Some("alice"), store)
|
||||
.with_extension(ext)
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
storage::path(root, "example.test", Some("bob"), store)
|
||||
.with_extension(ext)
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(extra.exists());
|
||||
assert!(hostwide.exists());
|
||||
a::remove_account(root, "example.test", "alice").unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn privacy_migration_only_default_deny_jids() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut v = V::table();
|
||||
v.set("test", V::Bool(true));
|
||||
storage::save(t.path(), "example.test", Some("alice"), "accounts", &v).unwrap();
|
||||
let old=storage::parse(b"return {default='main';lists={main={items={{type='jid';action='deny';value='BOB@EXAMPLE.TEST'};{type='jid';action='allow';value='carol@example.test'};{type='group';action='deny';value='Friends'}}};other={items={{type='jid';action='deny';value='other@example.test'}}}}}").unwrap();
|
||||
storage::save(t.path(), "example.test", Some("alice"), "privacy", &old).unwrap();
|
||||
assert!(
|
||||
a::load_blocklist(t.path(), "example.test", "alice", false)
|
||||
.unwrap()
|
||||
.entries()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(a::is_blocked(t.path(), "example.test", "alice", "bob@example.test/r").unwrap());
|
||||
assert!(!a::is_blocked(t.path(), "example.test", "alice", "carol@example.test").unwrap());
|
||||
assert!(!a::is_blocked(t.path(), "example.test", "alice", "other@example.test").unwrap());
|
||||
let b = a::load_blocklist(t.path(), "example.test", "alice", true).unwrap();
|
||||
assert_eq!(
|
||||
b.get_key(&V::Bool(false))
|
||||
.unwrap()
|
||||
.get("migrated")
|
||||
.and_then(V::as_str),
|
||||
Some("privacy")
|
||||
);
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "privacy")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn account_purge_refuses_symlink_store_without_touching_other_files() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let mut v = V::table();
|
||||
v.set("test", V::Bool(true));
|
||||
storage::save(t.path(), "example.test", Some("alice"), "accounts", &v).unwrap();
|
||||
std::fs::write(other.path().join("alice.dat"), b"external").unwrap();
|
||||
std::os::unix::fs::symlink(other.path(), t.path().join("example%2etest/linked")).unwrap();
|
||||
assert!(a::remove_account(t.path(), "example.test", "alice").is_err());
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "accounts")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(other.path().join("alice.dat").exists());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_bookmarks_respect_host_limit_and_read_over_256() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
a::set_legacy_pep_limit(t.path(), "example.test", 500).unwrap();
|
||||
legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='set' id='b'><query xmlns='jabber:iq:private'><storage xmlns='storage:bookmarks'><conference jid='seed@conference.example.test'/></storage></query></iq>",
|
||||
);
|
||||
let meta = storage::load(t.path(), "example.test", Some("alice"), "pep")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
meta.get("urn:xmpp:bookmarks:1")
|
||||
.unwrap()
|
||||
.get("config")
|
||||
.unwrap()
|
||||
.get("max_items"),
|
||||
Some(&V::Integer(500))
|
||||
);
|
||||
let store = "pep_urn:xmpp:bookmarks:1";
|
||||
let entries = (0..300)
|
||||
.map(|i| rust_xmpp::archive::Entry {
|
||||
id: format!("room{i}@conference.example.test"),
|
||||
when: 1700000000.0 + i as f64,
|
||||
peer: "alice@example.test".into(),
|
||||
xml: "<conference xmlns='urn:xmpp:bookmarks:1'/>".into(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
rust_xmpp::archive::save_store(t.path(), "example.test", Some("alice"), store, &entries)
|
||||
.unwrap();
|
||||
let s = a::legacy_bookmarks(t.path(), "example.test", "alice@example.test/r").unwrap();
|
||||
assert_eq!(s.matches("<conference ").count(), 300);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_roster_iq_never_mutates() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
for xml in [
|
||||
"<iq type='set' id='x'><query xmlns='jabber:iq:roster'><item xmlns='evil' jid='bob@example.test'/></query></iq>",
|
||||
"<iq type='set' id='x'><query xmlns='jabber:iq:roster'><item jid='bob@example.test'/></query><extra/></iq>",
|
||||
"<iq type='set'><query xmlns='jabber:iq:roster'><item jid='bob@example.test'/></query></iq>",
|
||||
] {
|
||||
assert!(iq(t.path(), xml).replies[0].contains("bad-request"));
|
||||
}
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "roster")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='x'><query xmlns='jabber:iq:roster'><item jid='missing@example.test' subscription='remove'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("item-not-found"));
|
||||
assert!(r.deliveries.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn broken_roster_is_not_overwritten() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let p = storage::path(t.path(), "example.test", Some("alice"), "roster");
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
let data=b"return {[false]={broken='read-failed';version=7};['bob@example.test']={subscription='both';groups={}}}";
|
||||
std::fs::write(&p, data).unwrap();
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='x'><query xmlns='jabber:iq:roster'><item jid='carol@example.test'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("internal-server-error"));
|
||||
assert_eq!(std::fs::read(&p).unwrap(), data);
|
||||
std::fs::write(&p, b"this is corrupt").unwrap();
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='get' id='x'><query xmlns='jabber:iq:roster'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("internal-server-error"));
|
||||
assert_eq!(std::fs::read(&p).unwrap(), b"this is corrupt");
|
||||
}
|
||||
#[test]
|
||||
fn roster_empty_name_and_foreign_group_ignored() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='get' id='x'><query xmlns='jabber:iq:roster'/></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("ver='1'"));
|
||||
iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='x'><query xmlns='jabber:iq:roster'><item jid='bob@example.test' name=''><group/><group xmlns='evil'>Hidden</group><group>Friends</group></item></query></iq>",
|
||||
);
|
||||
let v = storage::load(t.path(), "example.test", Some("alice"), "roster")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let item = v.get("bob@example.test").unwrap();
|
||||
assert!(item.get("name").is_none());
|
||||
let groups = item.get("groups").unwrap();
|
||||
assert_eq!(groups.entries().len(), 1);
|
||||
assert!(groups.get("Friends").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn creation_and_purge_are_serialized() {
|
||||
use rust_xmpp::auth;
|
||||
use std::sync::{Arc, Barrier};
|
||||
for _ in 0..5 {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
auth::create(t.path(), "example.test", "alice", "old", "SHA-256").unwrap();
|
||||
let mut v = V::table();
|
||||
v.set("old", "private-data".into());
|
||||
storage::save(t.path(), "example.test", Some("alice"), "private", &v).unwrap();
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
std::thread::scope(|scope| {
|
||||
let b = barrier.clone();
|
||||
let p = t.path();
|
||||
let new = scope.spawn(move || {
|
||||
b.wait();
|
||||
auth::create(p, "example.test", "alice", "new", "SHA-256")
|
||||
});
|
||||
barrier.wait();
|
||||
a::remove_account(t.path(), "example.test", "alice").unwrap();
|
||||
let created = new.join().unwrap();
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "private")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
if created.is_ok() {
|
||||
assert!(auth::verify(t.path(), "example.test", "alice", "new", "SHA-256").unwrap());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn password_change_after_purge_never_recreates_account() {
|
||||
use rust_xmpp::auth;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
auth::create(t.path(), "example.test", "alice", "old", "SHA-256").unwrap();
|
||||
a::remove_account(t.path(), "example.test", "alice").unwrap();
|
||||
assert!(auth::set_password(t.path(), "example.test", "alice", "new", "SHA-256").is_err());
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "accounts")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_legacy_vcard_and_private_bookmarks_rejected() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='set' id='x'><wrong xmlns='vcard-temp'><FN>Bad</FN></wrong></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("bad-request"));
|
||||
assert!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "pep")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let r = legacy(
|
||||
t.path(),
|
||||
"alice@example.test/r",
|
||||
"<iq type='set' id='x'><query xmlns='jabber:iq:private'><storage xmlns='storage:bookmarks'/><other xmlns='other'/></query></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("bad-request"));
|
||||
let r = iq(
|
||||
t.path(),
|
||||
"<iq type='set' id='b'><block xmlns='urn:xmpp:blocking'><item xmlns='evil' jid='bob@example.test'/></block></iq>",
|
||||
);
|
||||
assert!(r.replies[0].contains("bad-request"));
|
||||
assert!(!a::is_blocked(t.path(), "example.test", "alice", "bob@example.test").unwrap());
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use rust_xmpp::storage::{self, LuaValue as V};
|
||||
#[test]
|
||||
fn mixed_binary_roundtrip() {
|
||||
let src=b"return { [false]={version=4;pending={};}; [1]='one'; ['a\\000\\255']='\\001\\254'; ['alice@example.test']={subscription='both';groups={Friends=true}} }";
|
||||
let a = storage::parse(src).unwrap();
|
||||
let b = storage::parse(storage::serialize(&a).unwrap().as_bytes()).unwrap();
|
||||
assert_eq!(a.get_key(&V::Bool(false)), b.get_key(&V::Bool(false)));
|
||||
assert_eq!(
|
||||
a.get_key(&V::String(vec![b'a', 0, 255])),
|
||||
Some(&V::String(vec![1, 254]))
|
||||
);
|
||||
assert_eq!(a.get_key(&V::Integer(1)), b.get_key(&V::Integer(1)));
|
||||
let lua = mlua::Lua::new();
|
||||
let t: mlua::Table = lua.load(storage::serialize(&a).unwrap()).eval().unwrap();
|
||||
assert_eq!(
|
||||
t.get::<mlua::Table>(false)
|
||||
.unwrap()
|
||||
.get::<i64>("version")
|
||||
.unwrap(),
|
||||
4
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn path_and_atomic_update() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
storage::path(t.path(), "example.test", Some("a_b"), "pep_node")
|
||||
.strip_prefix(t.path())
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"example%2etest/pep_node/a%5fb.dat"
|
||||
);
|
||||
storage::update(t.path(), "example.test", Some("alice"), "roster", |v| {
|
||||
v.set("a", "old".into());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let result = storage::update(t.path(), "example.test", Some("alice"), "roster", |v| {
|
||||
v.set("a", "new".into());
|
||||
anyhow::bail!("abort")
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
storage::load(t.path(), "example.test", Some("alice"), "roster")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get("a")
|
||||
.and_then(V::as_str),
|
||||
Some("old")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rejects_cycles_code_and_bad_files() {
|
||||
assert!(storage::parse(b"return os.execute('true')").is_err());
|
||||
assert!(storage::parse(b"local t={};t.self=t;return t").is_err());
|
||||
assert!(storage::parse(b"while true do end").is_err());
|
||||
assert!(storage::parse(b"return {x=function()end}").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn stanza_roundtrip() {
|
||||
let d=roxmltree::Document::parse("<vCard xmlns='vcard-temp' xml:lang='ru'><FN>A & B</FN><x xmlns='other' a='b'/></vCard>").unwrap();
|
||||
let v = storage::stanza_value(d.root_element());
|
||||
assert_eq!(
|
||||
v.get("attr").unwrap().get("xml:lang").and_then(V::as_str),
|
||||
Some("ru")
|
||||
);
|
||||
let s =
|
||||
storage::stanza_xml(&storage::parse(storage::serialize(&v).unwrap().as_bytes()).unwrap())
|
||||
.unwrap();
|
||||
let doc = roxmltree::Document::parse(&s).unwrap();
|
||||
assert_eq!(
|
||||
doc.root_element()
|
||||
.attribute(("http://www.w3.org/XML/1998/namespace", "lang")),
|
||||
Some("ru")
|
||||
);
|
||||
assert_eq!(
|
||||
doc.root_element().first_element_child().unwrap().text(),
|
||||
Some("A & B")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn concurrent_updates_do_not_lose_entries() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
for n in 0..12 {
|
||||
let p = t.path();
|
||||
scope.spawn(move || {
|
||||
storage::update(p, "d", Some("a"), "test", |v| {
|
||||
v.set(&n.to_string(), V::Integer(n));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
storage::load(t.path(), "d", Some("a"), "test")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.entries()
|
||||
.len(),
|
||||
12
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn empty_namespace_preserved() {
|
||||
let d = roxmltree::Document::parse("<x xmlns='one'><y xmlns=''/></x>").unwrap();
|
||||
let s = storage::stanza_xml(&storage::stanza_value(d.root_element())).unwrap();
|
||||
let d = roxmltree::Document::parse(&s).unwrap();
|
||||
assert_eq!(
|
||||
d.root_element()
|
||||
.first_element_child()
|
||||
.unwrap()
|
||||
.tag_name()
|
||||
.namespace(),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
#[path = "../src/tls_binding.rs"]
|
||||
mod tls_binding;
|
||||
|
||||
use sha2::{Digest, Sha224, Sha256, Sha384, Sha512};
|
||||
use std::{path::Path, process::Command};
|
||||
use tls_binding::server_end_point;
|
||||
|
||||
fn openssl(dir: &Path, args: &[&str]) {
|
||||
let output = Command::new("openssl")
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("openssl must be installed for TLS certificate tests");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"openssl {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn certificate(dir: &Path, digest: &str, pss: bool) -> Vec<u8> {
|
||||
let mut args = vec![
|
||||
"req",
|
||||
"-new",
|
||||
"-x509",
|
||||
"-key",
|
||||
"key.pem",
|
||||
"-subj",
|
||||
"/CN=binding.test",
|
||||
"-days",
|
||||
"1",
|
||||
"-outform",
|
||||
"DER",
|
||||
"-out",
|
||||
"cert.der",
|
||||
digest,
|
||||
];
|
||||
if pss {
|
||||
args.extend([
|
||||
"-sigopt",
|
||||
"rsa_padding_mode:pss",
|
||||
"-sigopt",
|
||||
"rsa_pss_saltlen:digest",
|
||||
]);
|
||||
}
|
||||
openssl(dir, &args);
|
||||
std::fs::read(dir.join("cert.der")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_signature_digest_and_weak_hash_upgrade() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
openssl(
|
||||
dir.path(),
|
||||
&[
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"RSA",
|
||||
"-pkeyopt",
|
||||
"rsa_keygen_bits:2048",
|
||||
"-out",
|
||||
"key.pem",
|
||||
],
|
||||
);
|
||||
for digest in ["-md5", "-sha1", "-sha224", "-sha256", "-sha384", "-sha512"] {
|
||||
let cert = certificate(dir.path(), digest, false);
|
||||
let expected = match digest {
|
||||
"-sha224" => Sha224::digest(&cert).to_vec(),
|
||||
"-sha384" => Sha384::digest(&cert).to_vec(),
|
||||
"-sha512" => Sha512::digest(&cert).to_vec(),
|
||||
_ => Sha256::digest(&cert).to_vec(),
|
||||
};
|
||||
assert_eq!(server_end_point(&cert).unwrap(), expected, "{digest}");
|
||||
}
|
||||
for digest in ["-sha1", "-sha224", "-sha256", "-sha384", "-sha512"] {
|
||||
let cert = certificate(dir.path(), digest, true);
|
||||
let expected = match digest {
|
||||
"-sha224" => Sha224::digest(&cert).to_vec(),
|
||||
"-sha384" => Sha384::digest(&cert).to_vec(),
|
||||
"-sha512" => Sha512::digest(&cert).to_vec(),
|
||||
_ => Sha256::digest(&cert).to_vec(),
|
||||
};
|
||||
assert_eq!(server_end_point(&cert).unwrap(), expected, "PSS {digest}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecdsa_uses_certificate_signature_hash() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
openssl(
|
||||
dir.path(),
|
||||
&[
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"EC",
|
||||
"-pkeyopt",
|
||||
"ec_paramgen_curve:P-256",
|
||||
"-out",
|
||||
"key.pem",
|
||||
],
|
||||
);
|
||||
let cert = certificate(dir.path(), "-sha384", false);
|
||||
assert_eq!(
|
||||
server_end_point(&cert).unwrap(),
|
||||
Sha384::digest(&cert).to_vec()
|
||||
);
|
||||
for end in 0..cert.len() {
|
||||
assert!(
|
||||
server_end_point(&cert[..end]).is_err(),
|
||||
"accepted truncation at {end}"
|
||||
);
|
||||
}
|
||||
let mut trailing = cert;
|
||||
trailing.push(0);
|
||||
assert!(server_end_point(&trailing).is_err());
|
||||
}
|
||||
|
||||
fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
|
||||
assert!(value.len() < 128);
|
||||
let mut result = vec![tag, value.len() as u8];
|
||||
result.extend(value);
|
||||
result
|
||||
}
|
||||
fn envelope(algorithm: &[u8]) -> Vec<u8> {
|
||||
let mut contents = tlv(0x30, &[]);
|
||||
contents.extend(tlv(0x30, algorithm));
|
||||
contents.extend(tlv(0x03, &[0, 1]));
|
||||
tlv(0x30, &contents)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_eddsa_algorithms_fail_closed() {
|
||||
// Ed25519/Ed448 do not get a guessed SHA-256 binding.
|
||||
for oid in [&[0x2b, 0x65, 0x70][..], &[0x2b, 0x65, 0x71], &[0x2a, 3, 4]] {
|
||||
assert!(server_end_point(&envelope(&tlv(0x06, oid))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_der_and_pss_parameters_fail_closed() {
|
||||
for bytes in [
|
||||
vec![],
|
||||
vec![0x30, 0x80, 0, 0],
|
||||
vec![0x30, 0x81, 0],
|
||||
vec![0x30, 0xff],
|
||||
vec![0x30, 0x88, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
|
||||
] {
|
||||
assert!(server_end_point(&bytes).is_err());
|
||||
}
|
||||
let pss_oid = tlv(0x06, b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x0a");
|
||||
assert!(server_end_point(&envelope(&pss_oid)).is_err());
|
||||
let mut defaults = pss_oid.clone();
|
||||
defaults.extend(tlv(0x30, &[]));
|
||||
let cert = envelope(&defaults);
|
||||
assert_eq!(
|
||||
server_end_point(&cert).unwrap(),
|
||||
Sha256::digest(&cert).to_vec()
|
||||
);
|
||||
let hash = tlv(0x30, &tlv(0x06, b"\x60\x86\x48\x01\x65\x03\x04\x02\x02"));
|
||||
let sha384 = tlv(0xa0, &hash);
|
||||
// SHA384 with default MGF1-SHA1 has multiple hash functions.
|
||||
let mut mismatch = pss_oid.clone();
|
||||
mismatch.extend(tlv(0x30, &sha384));
|
||||
assert!(server_end_point(&envelope(&mismatch)).is_err());
|
||||
for params in [
|
||||
vec![0xa4, 0],
|
||||
vec![0xa0, 0],
|
||||
vec![0xa2, 3, 2, 1, 0xff],
|
||||
vec![0xa3, 3, 2, 1, 2],
|
||||
[sha384.clone(), sha384].concat(),
|
||||
] {
|
||||
let mut algorithm = pss_oid.clone();
|
||||
algorithm.extend(tlv(0x30, ¶ms));
|
||||
assert!(server_end_point(&envelope(&algorithm)).is_err());
|
||||
}
|
||||
let mut invalid_oid = tlv(0x06, &[0x80, 0x2a]);
|
||||
invalid_oid.extend([5, 0]);
|
||||
assert!(server_end_point(&envelope(&invalid_oid)).is_err());
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Independent SCRAM-PLUS client against the actual STARTTLS listener.
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as B64};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rust_xmpp::{auth, wire::Framer};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::BufReader,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncWrite, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
};
|
||||
use tokio_rustls::{
|
||||
TlsConnector,
|
||||
rustls::{self, RootCertStore, pki_types::ServerName},
|
||||
};
|
||||
|
||||
const STREAM: &str = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>";
|
||||
const SASL1: &str = "urn:ietf:params:xml:ns:xmpp-sasl";
|
||||
const SASL2: &str = "urn:xmpp:sasl:2";
|
||||
const CB_NS: &str = "urn:xmpp:sasl-cb:0";
|
||||
const WAIT: Duration = Duration::from_secs(5);
|
||||
const PASSWORD: &str = "loopback-plus-password";
|
||||
|
||||
struct Process(Child);
|
||||
impl Drop for Process {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
struct Server {
|
||||
process: Process,
|
||||
_dir: tempfile::TempDir,
|
||||
port: u16,
|
||||
cert: rustls::pki_types::CertificateDer<'static>,
|
||||
}
|
||||
impl Server {
|
||||
async fn start(disable_plus: bool) -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = fs::File::create(dir.path().join("openssl.log")).unwrap();
|
||||
let mut openssl = Process(
|
||||
Command::new("openssl")
|
||||
.current_dir(dir.path())
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-sha256",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"1",
|
||||
"-keyout",
|
||||
"key.pem",
|
||||
"-out",
|
||||
"cert.pem",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-addext",
|
||||
"extendedKeyUsage=serverAuth",
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(log)
|
||||
.spawn()
|
||||
.expect("openssl must be installed"),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
loop {
|
||||
if let Some(status) = openssl.0.try_wait().unwrap() {
|
||||
assert!(
|
||||
status.success(),
|
||||
"openssl failed: {}",
|
||||
fs::read_to_string(dir.path().join("openssl.log")).unwrap()
|
||||
);
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "openssl timed out");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let cert = rustls_pemfile::certs(&mut BufReader::new(
|
||||
fs::File::open(dir.path().join("cert.pem")).unwrap(),
|
||||
))
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = reservation.local_addr().unwrap().port();
|
||||
let data = dir.path().join("data");
|
||||
auth::create(&data, "localhost", "alice", PASSWORD, "SHA-256").unwrap();
|
||||
let mut config = serde_json::json!({
|
||||
"global": {"data_path": data, "interfaces": ["127.0.0.1"], "c2s_ports": [port],
|
||||
"s2s_ports": [], "http_ports": [], "https_ports": [],
|
||||
"modules_enabled": ["tls", "saslauth", "sasl2", "sasl_ssdp"]},
|
||||
"hosts": {"localhost": {"options": {"authentication": "internal_hashed", "password_hash": "SHA-256",
|
||||
"ssl": {"key": dir.path().join("key.pem"), "certificate": dir.path().join("cert.pem")}}}}
|
||||
});
|
||||
if disable_plus {
|
||||
config["hosts"]["localhost"]["options"]["disable_sasl_mechanisms"] =
|
||||
serde_json::json!(["SCRAM-SHA-256-PLUS"]);
|
||||
}
|
||||
let path = dir.path().join("config.json");
|
||||
fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap();
|
||||
let log = fs::File::create(dir.path().join("server.log")).unwrap();
|
||||
drop(reservation);
|
||||
let process = Process(
|
||||
Command::new(env!("CARGO_BIN_EXE_rust-xmpp"))
|
||||
.arg("serve")
|
||||
.arg(path)
|
||||
.arg("--experimental")
|
||||
.stdout(log.try_clone().unwrap())
|
||||
.stderr(log)
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut server = Self {
|
||||
process,
|
||||
_dir: dir,
|
||||
port,
|
||||
cert,
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
assert!(
|
||||
server.process.0.try_wait().unwrap().is_none(),
|
||||
"server exited: {}",
|
||||
server.logs()
|
||||
);
|
||||
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("server startup timed out: {}", server.logs()));
|
||||
server
|
||||
}
|
||||
fn logs(&self) -> String {
|
||||
fs::read_to_string(self._dir.path().join("server.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn send<S: AsyncWrite + Unpin>(stream: &mut S, xml: &str) {
|
||||
tokio::time::timeout(WAIT, stream.write_all(xml.as_bytes()))
|
||||
.await
|
||||
.expect("write timeout")
|
||||
.unwrap();
|
||||
}
|
||||
async fn next<S: AsyncRead + Unpin>(framer: &mut Framer, stream: &mut S) -> String {
|
||||
tokio::time::timeout(WAIT, framer.next(stream))
|
||||
.await
|
||||
.expect("read timeout")
|
||||
.unwrap()
|
||||
}
|
||||
async fn features<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
stream: &mut S,
|
||||
framer: &mut Framer,
|
||||
) -> String {
|
||||
send(stream, STREAM).await;
|
||||
loop {
|
||||
let xml = next(framer, stream).await;
|
||||
if xml.contains("<stream:features") {
|
||||
return framer.wrap(&xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn mac(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut h = Hmac::<Sha256>::new_from_slice(key).unwrap();
|
||||
h.update(data);
|
||||
h.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
async fn exchange(
|
||||
server: &Server,
|
||||
tls13: bool,
|
||||
binding_name: &str,
|
||||
sasl2: bool,
|
||||
bad_binding: bool,
|
||||
) {
|
||||
let mut tcp = tokio::time::timeout(WAIT, TcpStream::connect(("127.0.0.1", server.port)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut f = Framer::default();
|
||||
let plain = features(&mut tcp, &mut f).await;
|
||||
assert!(plain.contains("starttls"), "{plain}");
|
||||
assert!(
|
||||
!plain.contains("SCRAM-SHA-256-PLUS"),
|
||||
"PLUS must not be offered before TLS"
|
||||
);
|
||||
send(
|
||||
&mut tcp,
|
||||
"<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>",
|
||||
)
|
||||
.await;
|
||||
let proceed = next(&mut f, &mut tcp).await;
|
||||
assert!(proceed.contains("proceed"), "{proceed}");
|
||||
assert!(f.buffer.is_empty());
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(server.cert.clone()).unwrap();
|
||||
let versions = if tls13 {
|
||||
&[&rustls::version::TLS13][..]
|
||||
} else {
|
||||
&[&rustls::version::TLS12][..]
|
||||
};
|
||||
let config = rustls::ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_protocol_versions(versions)
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let mut tls = tokio::time::timeout(
|
||||
WAIT,
|
||||
TlsConnector::from(Arc::new(config))
|
||||
.connect(ServerName::try_from("localhost").unwrap(), tcp),
|
||||
)
|
||||
.await
|
||||
.expect("TLS timeout")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tls.get_ref().1.protocol_version(),
|
||||
Some(if tls13 {
|
||||
rustls::ProtocolVersion::TLSv1_3
|
||||
} else {
|
||||
rustls::ProtocolVersion::TLSv1_2
|
||||
})
|
||||
);
|
||||
let plus = binding_name != "none";
|
||||
let mechanism = if plus {
|
||||
"SCRAM-SHA-256-PLUS"
|
||||
} else {
|
||||
"SCRAM-SHA-256"
|
||||
};
|
||||
let mut binding = if !plus {
|
||||
Vec::new()
|
||||
} else if binding_name == "tls-exporter" {
|
||||
tls.get_ref()
|
||||
.1
|
||||
.export_keying_material([0u8; 32], b"EXPORTER-Channel-Binding", Some(b""))
|
||||
.unwrap()
|
||||
.to_vec()
|
||||
} else {
|
||||
Sha256::digest(server.cert.as_ref()).to_vec()
|
||||
};
|
||||
let mut f = Framer::default();
|
||||
let advertised = features(&mut tls, &mut f).await;
|
||||
let doc = roxmltree::Document::parse(&advertised).unwrap();
|
||||
let mechanism_ns = if sasl2 { SASL2 } else { SASL1 };
|
||||
let mut mechanisms: Vec<_> = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name((mechanism_ns, "mechanism")))
|
||||
.map(|n| n.text().unwrap().to_owned())
|
||||
.collect();
|
||||
mechanisms.sort();
|
||||
mechanisms.dedup();
|
||||
assert!(mechanisms.iter().any(|m| m == mechanism));
|
||||
assert_eq!(mechanisms.iter().any(|m| m == "SCRAM-SHA-256-PLUS"), plus);
|
||||
let mut bindings: Vec<_> = doc
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name((CB_NS, "channel-binding")))
|
||||
.map(|n| n.attribute("type").unwrap().to_owned())
|
||||
.collect();
|
||||
bindings.sort();
|
||||
bindings.dedup();
|
||||
assert_eq!(
|
||||
bindings,
|
||||
if !plus {
|
||||
vec![]
|
||||
} else if tls13 {
|
||||
vec!["tls-exporter", "tls-server-end-point"]
|
||||
} else {
|
||||
vec!["tls-server-end-point"]
|
||||
}
|
||||
);
|
||||
let gs2 = if plus {
|
||||
format!("p={binding_name},,")
|
||||
} else {
|
||||
"y,,".to_owned()
|
||||
};
|
||||
let bare = "n=alice,r=independent-client-nonce";
|
||||
let first = B64.encode(format!("{gs2}{bare}"));
|
||||
let ns = mechanism_ns;
|
||||
if sasl2 {
|
||||
send(&mut tls, &format!("<authenticate xmlns='{ns}' mechanism='{mechanism}'><initial-response>{first}</initial-response></authenticate>")).await;
|
||||
} else {
|
||||
send(
|
||||
&mut tls,
|
||||
&format!("<auth xmlns='{ns}' mechanism='{mechanism}'>{first}</auth>"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let xml = next(&mut f, &mut tls).await;
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
assert!(doc.root_element().has_tag_name((ns, "challenge")), "{xml}");
|
||||
let challenge =
|
||||
String::from_utf8(B64.decode(doc.root_element().text().unwrap()).unwrap()).unwrap();
|
||||
let attrs: BTreeMap<_, _> = challenge
|
||||
.split(',')
|
||||
.map(|a| a.split_once('=').unwrap())
|
||||
.collect();
|
||||
assert!(attrs["r"].starts_with("independent-client-nonce"));
|
||||
assert!(attrs["r"].len() > "independent-client-nonce".len());
|
||||
let ssdp_source = if bindings.is_empty() {
|
||||
mechanisms.join("\x1e")
|
||||
} else {
|
||||
format!("{}\x1f{}", mechanisms.join("\x1e"), bindings.join("\x1e"))
|
||||
};
|
||||
assert_eq!(
|
||||
attrs.get("h").copied(),
|
||||
Some(B64.encode(Sha256::digest(ssdp_source.as_bytes())).as_str()),
|
||||
"SSDP must authenticate the entire advertised mechanism and binding lists"
|
||||
);
|
||||
let iterations: u32 = attrs["i"].parse().unwrap();
|
||||
assert!((1..=1_000_000).contains(&iterations));
|
||||
let mut salted = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
PASSWORD.as_bytes(),
|
||||
&B64.decode(attrs["s"]).unwrap(),
|
||||
iterations,
|
||||
&mut salted,
|
||||
);
|
||||
if bad_binding {
|
||||
binding[0] ^= 1;
|
||||
}
|
||||
let mut cbind = gs2.into_bytes();
|
||||
cbind.extend_from_slice(&binding);
|
||||
let without = format!("c={},r={}", B64.encode(cbind), attrs["r"]);
|
||||
let auth_message = format!("{bare},{challenge},{without}");
|
||||
let client_key = mac(&salted, b"Client Key");
|
||||
let signature = mac(&Sha256::digest(&client_key), auth_message.as_bytes());
|
||||
let proof: Vec<_> = client_key
|
||||
.iter()
|
||||
.zip(signature)
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
// Even the tampered-binding exchange has a valid password proof for its transcript.
|
||||
let final_message = format!("{without},p={}", B64.encode(proof));
|
||||
send(
|
||||
&mut tls,
|
||||
&format!(
|
||||
"<response xmlns='{ns}'>{}</response>",
|
||||
B64.encode(final_message)
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let xml = next(&mut f, &mut tls).await;
|
||||
let doc = roxmltree::Document::parse(&xml).unwrap();
|
||||
let result = doc.root_element();
|
||||
if bad_binding {
|
||||
assert!(
|
||||
result.has_tag_name((ns, "failure")),
|
||||
"tampered binding authenticated: {xml}"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.descendants()
|
||||
.any(|n| n.has_tag_name((SASL1, "not-authorized"))),
|
||||
"{xml}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert!(result.has_tag_name((ns, "success")), "{xml}");
|
||||
let final_data = if sasl2 {
|
||||
assert_eq!(
|
||||
result
|
||||
.children()
|
||||
.find(|n| n.has_tag_name((SASL2, "authorization-identifier")))
|
||||
.and_then(|n| n.text()),
|
||||
Some("alice@localhost")
|
||||
);
|
||||
result
|
||||
.children()
|
||||
.find(|n| n.has_tag_name((SASL2, "additional-data")))
|
||||
.unwrap()
|
||||
.text()
|
||||
.unwrap()
|
||||
} else {
|
||||
result.text().unwrap()
|
||||
};
|
||||
let expected = format!(
|
||||
"v={}",
|
||||
B64.encode(mac(&mac(&salted, b"Server Key"), auth_message.as_bytes()))
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(B64.decode(final_data).unwrap()).unwrap(),
|
||||
expected,
|
||||
"verify the full server signature"
|
||||
);
|
||||
if !sasl2 {
|
||||
f = Framer::default();
|
||||
}
|
||||
let post_auth = if sasl2 {
|
||||
let xml = next(&mut f, &mut tls).await;
|
||||
f.wrap(&xml)
|
||||
} else {
|
||||
features(&mut tls, &mut f).await
|
||||
};
|
||||
assert!(
|
||||
post_auth.contains("urn:ietf:params:xml:ns:xmpp-bind"),
|
||||
"{post_auth}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn starttls_scram_plus_binds_real_tls_and_authenticates_ssdp_sasl1_and_sasl2() {
|
||||
tokio::time::timeout(Duration::from_secs(90), async {
|
||||
let server = Server::start(false).await;
|
||||
for (tls13, binding) in [
|
||||
(true, "tls-exporter"),
|
||||
(true, "tls-server-end-point"),
|
||||
(false, "tls-server-end-point"),
|
||||
] {
|
||||
for sasl2 in [false, true] {
|
||||
exchange(&server, tls13, binding, sasl2, false).await;
|
||||
exchange(&server, tls13, binding, sasl2, true).await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded STARTTLS integration test");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn disabling_plus_hides_bindings_and_accepts_scram_y_with_mechanisms_only_ssdp() {
|
||||
tokio::time::timeout(Duration::from_secs(45), async {
|
||||
let server = Server::start(true).await;
|
||||
for sasl2 in [false, true] {
|
||||
exchange(&server, true, "none", sasl2, false).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded no-PLUS STARTTLS integration test");
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verified-TLS browser-style RFC 7395 handshake regression.
|
||||
|
||||
Requires Python 3.11+, websockets and openssl. Starts an HTTPS-only server
|
||||
bound to loopback; no credentials, external network or system trust changes.
|
||||
Usage: python3 tests/wss_interop.py [--binary target/release/rust-xmpp]
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import websockets
|
||||
|
||||
ORIGIN = 'https://conversejs.wthome.ru'
|
||||
FRAMING = 'urn:ietf:params:xml:ns:xmpp-framing'
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def http_status(port, context, path='/xmpp-websocket', origin=ORIGIN, protocol='xmpp', address='127.0.0.1'):
|
||||
"""Send browser handshake headers over certificate-verified TLS."""
|
||||
headers = [f'GET {path} HTTP/1.1', f'Host: localhost:{port}',
|
||||
'Connection: keep-alive, Upgrade', 'Upgrade: websocket',
|
||||
'Sec-WebSocket-Version: 13',
|
||||
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==']
|
||||
if origin is not None:
|
||||
headers.append(f'Origin: {origin}')
|
||||
if protocol is not None:
|
||||
headers.append(f'Sec-WebSocket-Protocol: {protocol}')
|
||||
with socket.create_connection((address, port), timeout=5) as tcp:
|
||||
with context.wrap_socket(tcp, server_hostname='localhost') as stream:
|
||||
stream.sendall(('\r\n'.join(headers) + '\r\n\r\n').encode())
|
||||
response = b''
|
||||
while b'\r\n\r\n' not in response:
|
||||
chunk = stream.recv(4096)
|
||||
assert chunk, response
|
||||
response += chunk
|
||||
return int(response.split(b' ', 2)[1]), response
|
||||
|
||||
|
||||
async def exercise(port, context):
|
||||
for path in ('/xmpp-websocket', '/xmpp-websocket/'):
|
||||
async with websockets.connect(f'wss://localhost:{port}{path}', ssl=context,
|
||||
origin=ORIGIN, subprotocols=['xmpp'], open_timeout=5) as ws:
|
||||
assert ws.subprotocol == 'xmpp', ws.subprotocol
|
||||
await ws.send(f'<open xmlns="{FRAMING}" to="localhost" version="1.0"/>')
|
||||
opened = ET.fromstring(await asyncio.wait_for(ws.recv(), timeout=5))
|
||||
assert opened.tag == f'{{{FRAMING}}}open', ET.tostring(opened)
|
||||
features = ET.fromstring(await asyncio.wait_for(ws.recv(), timeout=5))
|
||||
assert features.tag == '{http://etherx.jabber.org/streams}features', ET.tostring(features)
|
||||
await ws.send(f'<close xmlns="{FRAMING}"/>')
|
||||
closed = ET.fromstring(await asyncio.wait_for(ws.recv(), timeout=5))
|
||||
assert closed.tag == f'{{{FRAMING}}}close', ET.tostring(closed)
|
||||
|
||||
|
||||
|
||||
def verify_original_origin_policy(binary, config, root, port, context):
|
||||
"""Reproduce the deployment mismatch before allowing the Converse page."""
|
||||
original = json.loads(json.dumps(config))
|
||||
original['global']['http_cors_override']['websocket']['origins'] = ['https://wthome.ru']
|
||||
path = root / 'original-origin-config.json'
|
||||
path.write_text(json.dumps(original))
|
||||
with (root / 'original-origin.log').open('w+') as log:
|
||||
server = subprocess.Popen([binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'original-origin server stopped during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('original-origin HTTPS server startup timed out')
|
||||
status, headers = http_status(port, context, origin='https://wthome.ru')
|
||||
assert status == 101, headers
|
||||
status, headers = http_status(port, context, origin=ORIGIN)
|
||||
assert status == 403, headers
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print('ORIGINAL ORIGIN SERVER LOG:\n' + log.read())
|
||||
raise
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', default=str(Path(__file__).resolve().parents[1] / 'target/release/rust-xmpp'))
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-wss-interop-') as temporary:
|
||||
root = Path(temporary)
|
||||
cert, key = root / 'cert.pem', root / 'key.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256',
|
||||
'-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost',
|
||||
'-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(key), '-out', str(cert)],
|
||||
check=True, capture_output=True)
|
||||
port = free_port()
|
||||
interfaces = ['127.0.0.1']
|
||||
try:
|
||||
with socket.socket(socket.AF_INET6) as probe:
|
||||
probe.bind(('::1', 0))
|
||||
interfaces.append('::1')
|
||||
except OSError:
|
||||
pass
|
||||
config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
|
||||
'https_interfaces': interfaces, 'c2s_ports': [], 's2s_ports': [],
|
||||
'http_ports': [], 'https_ports': [port],
|
||||
'log_format': 'json',
|
||||
'modules_enabled': ['tls', 'websocket', 'saslauth'],
|
||||
'http_cors_override': {'websocket': {'origins': [ORIGIN]}},
|
||||
'ssl': {'certificate': str(cert), 'key': str(key)}},
|
||||
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}}
|
||||
path = root / 'config.json'
|
||||
path.write_text(json.dumps(config))
|
||||
context = ssl.create_default_context(cafile=str(cert))
|
||||
verify_original_origin_policy(args.binary, config, root, port, context)
|
||||
with (root / 'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([args.binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert server.poll() is None, 'server stopped during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('HTTPS-only server startup timed out')
|
||||
# Normal browser Origin, with token-list Connection header.
|
||||
status, headers = http_status(port, context)
|
||||
assert status == 101, headers
|
||||
assert b'sec-websocket-protocol: xmpp' in headers.lower(), headers
|
||||
if '::1' in interfaces:
|
||||
status, headers = http_status(port, context, address='::1')
|
||||
assert status == 101, headers
|
||||
for origin in ('https://PRIVATE-ORIGIN.example', ORIGIN + ':8443', 'null'):
|
||||
status, headers = http_status(port, context, origin=origin)
|
||||
assert status == 403, (origin, headers)
|
||||
for protocol in (None, 'chat', 'XMPP'):
|
||||
status, headers = http_status(port, context, protocol=protocol)
|
||||
assert status == 400, (protocol, headers)
|
||||
status, headers = http_status(port, context, path='/PRIVATE-PATH?token=PRIVATE-TOKEN')
|
||||
assert status == 404, headers
|
||||
# Native clients may omit Origin; they must still negotiate xmpp.
|
||||
status, headers = http_status(port, context, origin=None, protocol='chat, xmpp')
|
||||
assert status == 101, headers
|
||||
try:
|
||||
http_status(port, ssl.create_default_context())
|
||||
except ssl.SSLCertVerificationError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('untrusted TLS certificate was accepted')
|
||||
asyncio.run(exercise(port, context))
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
logged = log.read()
|
||||
events = [json.loads(line) for line in logged.splitlines() if line.startswith('{')]
|
||||
http_events = [e for e in events if e.get('module') == 'http']
|
||||
assert any(e['event'] == 'connection-open' for e in http_events), logged
|
||||
listeners = [dict((f['key'], f['value']) for f in e['fields'])
|
||||
for e in http_events if e['event'] == 'listener-ready']
|
||||
assert len(listeners) == len(interfaces), listeners
|
||||
assert all(e.get('transport') == 'https' for e in listeners), listeners
|
||||
assert any(e['event'] == 'tls-established' for e in http_events), logged
|
||||
requests = [dict((f['key'], f['value']) for f in e['fields'])
|
||||
for e in http_events if e['event'] == 'request-complete']
|
||||
for status in ('101', '400', '403', '404'):
|
||||
assert any(r.get('status') == status for r in requests), (status, requests)
|
||||
assert all(r.get('route') in ('websocket', 'bosh', 'other') for r in requests), requests
|
||||
assert all(secret not in logged for secret in
|
||||
('PRIVATE-PATH', 'PRIVATE-TOKEN', 'PRIVATE-ORIGIN', 'dGhlIHNhbXBsZSBub25jZQ==')), logged
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print('SERVER LOG:\n' + log.read())
|
||||
raise
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
print('PASS: HTTPS-only configured interfaces (' + ','.join(interfaces) + '), verified TLS, reproduced original Origin 403 then allowed Converse Origin 101, xmpp subprotocol, both WS paths, RFC 7395 open/features/close and private HTTP diagnostics')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user