initial commit

This commit is contained in:
wt committed 2026-10-20 15:59:55 +07:00
commit c260d7bab8
235 files changed
+49786

No files matched your search

+54
View File
@@ -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()
+103
View File
@@ -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());
}
+197
View File
@@ -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:])
+591
View File
@@ -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>Привет &amp; 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&lt;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")))
);
}
+57
View File
@@ -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());
}
}
}
+58
View File
@@ -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
View File
@@ -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));
}
}
+64
View File
@@ -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);
}
+104
View File
@@ -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}"
);
}
}
+52
View File
@@ -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);
}
+20
View File
@@ -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");
}
+582
View File
@@ -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();
}
+252
View File
@@ -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" }
+190
View File
@@ -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))
+234
View File
@@ -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}"
);
}
}
}
+62
View File
@@ -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());
}
}
}
+212
View File
@@ -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()
+353
View File
@@ -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()
+482
View File
@@ -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;
}
+306
View File
@@ -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();
}
+35
View File
@@ -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&amp;bob@example.test'"] {
let request = format!("<iq id='a&amp;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"));
}
}
+263
View File
@@ -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());
}
+270
View File
@@ -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))
+76
View File
@@ -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());
}
+113
View File
@@ -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"));
}
+87
View File
@@ -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")
);
}
+387
View File
@@ -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"))
);
}
+481
View File
@@ -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")
);
}
+202
View File
@@ -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);
}
+97
View File
@@ -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"));
}
+211
View File
@@ -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 &amp; 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()
);
}
+188
View File
@@ -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()))
+106
View File
@@ -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")));
}
+140
View File
@@ -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"));
}
+485
View File
@@ -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();
}
}
+157
View File
@@ -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()))
+98
View File
@@ -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"));
}
}
+194
View File
@@ -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}")));
}
}
+134
View File
@@ -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)
);
}
+168
View File
@@ -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()))
+126
View File
@@ -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()))
+368
View File
@@ -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()
);
}
+118
View File
@@ -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()
);
}
+73
View File
@@ -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()
+71
View File
@@ -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'"));
}
+951
View File
@@ -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
View File
@@ -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 = &notifications[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 = &notifications[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)
);
}
+148
View File
@@ -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()))
+44
View File
@@ -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
+428
View File
@@ -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;
+102
View File
@@ -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);
}
}
+191
View File
@@ -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)
+230
View File
@@ -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, &current));
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()));
}
+173
View File
@@ -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&amp;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()))
+168
View File
@@ -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:])
+144
View File
@@ -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()))
+37
View File
@@ -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());
}
+93
View File
@@ -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')
+131
View File
@@ -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()))
+172
View File
@@ -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()
+605
View File
@@ -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());
}
+119
View File
@@ -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 &amp; 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("")
);
}
+179
View File
@@ -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, &params));
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());
}
+445
View File
@@ -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");
}
+206
View File
@@ -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()