Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-nbxmpp for openSUSE:Factory checked in at 2026-10-29 16:11:22 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-nbxmpp (Old) and /work/SRC/openSUSE:Factory/.python-nbxmpp.new.1255 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-nbxmpp" Thu Oct 29 16:11:22 2026 rev:59 rq:1393621 version:7.4.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-nbxmpp/python-nbxmpp.changes 2026-04-21 12:46:25.674424351 +0200 +++ /work/SRC/openSUSE:Factory/.python-nbxmpp.new.1255/python-nbxmpp.changes 2026-10-29 16:13:01.892898228 +0100 @@ -1,0 +2,22 @@ +Tue Oct 27 21:02:53 UTC 2026 - Bjørn Lie <[email protected]> + +- Update to version 7.4.0: + * New: Client: Add better disconnect check + * Improvements: + + VCardAvatars: Validate and normalize sha1 + + Fallback: Improve range validation + + Use secrets module instead of random + + OMEMO: Harden bundle parser + + OMEMO: Harden devicelist parser + * Bug Fixes: + + WebSocket: Disconnect on parsing errors + + VCardTemp: Guard against base64 decoding errors + + Client: Add timeout when waiting for stream end + + MDS: Add request_items dependency +- Changes from version 7.3.0: + * New: Add helper method parse_account_invite() + * Improvements: OpenPGP: Don't add fallback body text + * Bug Fixes: EME: Make name always a string +- Update version dependencies according to meson.build. + +------------------------------------------------------------------- Old: ---- python-nbxmpp-7.2.0.tar.bz2 New: ---- python-nbxmpp-7.4.0.tar.bz2 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-nbxmpp.spec ++++++ --- /var/tmp/diff_new_pack.LUdKpr/_old 2026-10-29 16:13:02.596927753 +0100 +++ /var/tmp/diff_new_pack.LUdKpr/_new 2026-10-29 16:13:02.603928047 +0100 @@ -19,7 +19,7 @@ %define _name nbxmpp %{?sle15_python_module_pythons} Name: python-nbxmpp -Version: 7.2.0 +Version: 7.4.0 Release: 0 Summary: XMPP library by Gajim team License: GPL-3.0-or-later @@ -33,7 +33,7 @@ BuildRequires: %{python_module packaging} BuildRequires: %{python_module pip} BuildRequires: %{python_module precis-i18n >= 1.0.0} -BuildRequires: %{python_module setuptools >= 65.0} +BuildRequires: %{python_module setuptools >= 65.0.0} BuildRequires: %{python_module sqlite3} BuildRequires: %{python_module wheel} BuildRequires: fdupes ++++++ python-nbxmpp-7.2.0.tar.bz2 -> python-nbxmpp-7.4.0.tar.bz2 ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/ChangeLog new/python-nbxmpp-7.4.0/ChangeLog --- old/python-nbxmpp-7.2.0/ChangeLog 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/ChangeLog 2026-08-26 18:54:51.000000000 +0200 @@ -1,3 +1,38 @@ +nbxmpp 7.4.0 (26 Aug 2026) + + New + + * Client: Add better disconnect check + + Improvements + + * VCardAvatars: Validate and normalize sha1 + * Fallback: Improve range validation + * Use secrets module instead of random + * OMEMO: Harden bundle parser + * OMEMO: Harden devicelist parser + + Bug Fixes + + * WebSocket: Disconnect on parsing errors + * VCardTemp: Guard against base64 decoding errors + * Client: Add timeout when waiting for stream end + * MDS: Add request_items dependency + +nbxmpp 7.3.0 (30 Jun 2026) + + New + + * Add helper method parse_account_invite() + + Improvements + + * OpenPGP: Don't add fallback body text + + Bug Fixes + + * EME: Make name always a string + nbxmpp 7.2.0 (19 Apr 2026) New diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/__init__.py new/python-nbxmpp-7.4.0/nbxmpp/__init__.py --- old/python-nbxmpp-7.2.0/nbxmpp/__init__.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/__init__.py 2026-08-26 18:54:51.000000000 +0200 @@ -4,4 +4,4 @@ from .protocol import * # noqa: F403, E402 -__version__: str = "7.2.0" +__version__: str = "7.4.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/client.py new/python-nbxmpp-7.4.0/nbxmpp/client.py --- old/python-nbxmpp-7.2.0/nbxmpp/client.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/client.py 2026-08-26 18:54:51.000000000 +0200 @@ -28,9 +28,6 @@ from nbxmpp.const import StreamError from nbxmpp.const import StreamState from nbxmpp.dispatcher import StanzaDispatcher -from nbxmpp.errors import CancelledError -from nbxmpp.errors import StanzaError -from nbxmpp.errors import TimeoutStanzaError from nbxmpp.namespaces import Namespace from nbxmpp.protocol import BindRequest from nbxmpp.protocol import Features @@ -118,6 +115,11 @@ log = logging.getLogger("nbxmpp.stream") +STREAM_END_TIMEOUT_SEC = 2 +CONNECTION_CHECK_INTERVAL = 30 +CONNECTION_CHECK_TIMEOUT = 3 + + class Client(Observable): def __init__(self, log_context: str | None = None) -> None: """ @@ -164,14 +166,14 @@ self._sm_disabled = False + self._stream_end_timeout_id: int | None = None self._stream_id: str | None = None self._stream_secure = False self._stream_authenticated = False self._stream_features: Features | None = None self._session_required = False self._connect_successful = False - self._stream_close_initiated = False - self._ping_task: Task | None = None + self._connection_check_timeout_id: int | None = None self._error: tuple[StreamError | None, str | None, str | None] = ( None, None, @@ -191,7 +193,6 @@ self._fallback_ns: set[str] = set() - self._ping_source_id: int | None = None self._tasks: list[Task] = [] self._dispatcher = StanzaDispatcher(self) @@ -204,6 +205,8 @@ self._state: StreamState = StreamState.DISCONNECTED + self._connection_check_id = self._enable_connection_check() + def add_task(self, task: Task) -> None: self._tasks.append(task) @@ -569,19 +572,48 @@ self._disconnect(immediate=immediate) - def _disconnect(self, immediate: bool = True) -> None: - self.state = StreamState.DISCONNECTING - self._remove_ping_timer() - self._cancel_ping_task() + def _start_stream_end_timeout(self) -> None: + if self._stream_end_timeout_id is not None: + return - if not immediate: - self._stream_close_initiated = True - assert self._smacks is not None - self._smacks.close_session() - self._end_stream() - self._con.shutdown_output() - else: + self._log.info("Start stream end timeout") + self._stream_end_timeout_id = GLib.timeout_add_seconds( + STREAM_END_TIMEOUT_SEC, self._on_stream_end_timeout + ) + + def _remove_stream_end_timeout(self) -> None: + if self._stream_end_timeout_id is None: + return + + self._log.info("Remove stream end timeout") + GLib.source_remove(self._stream_end_timeout_id) + self._stream_end_timeout_id = None + + def _on_stream_end_timeout(self) -> None: + self._stream_end_timeout_id = None + if self._state != StreamState.DISCONNECTING: + return + + self._log.warning("Timeout while waiting for stream end") + self._con.shutdown_input() + + def _disconnect(self, immediate: bool = True) -> None: + self._log.info("Disconnect immediate=%s", immediate) + if immediate: + self.state = StreamState.DISCONNECTING + self._remove_connection_check_timeout() self._con.disconnect() + else: + self._start_stream_end_timeout() + self._graceful_disconnect() + + def _graceful_disconnect(self) -> None: + self.state = StreamState.DISCONNECTING + self._remove_connection_check_timeout() + assert self._smacks is not None + self._smacks.close_session() + self._end_stream() + self._con.shutdown_output() def send(self, stanza: Protocol, *args: Any, **kwargs: Any) -> str: # Alias for backwards compat @@ -596,8 +628,6 @@ self.state = StreamState.DISCONNECTED for task in self._tasks: task.cancel() - self._remove_ping_timer() - self._cancel_ping_task() self._reset_stream() self.notify("disconnected") @@ -637,15 +667,12 @@ if not self.has_error: self._set_error(StreamError.STREAM, error or "stream-end") + self._remove_stream_end_timeout() self._con.shutdown_input() - if not self._stream_close_initiated: - self.state = StreamState.DISCONNECTING - self._remove_ping_timer() - self._cancel_ping_task() - assert self._smacks is not None - self._smacks.close_session() - self._end_stream() - self._con.shutdown_output() + if self._state not in (StreamState.DISCONNECTING, StreamState.DISCONNECTED): + # If we are in DISCONNECTING or DISCONNECTED this means we initiated + # the disconnect, so we dont need to call disconnect again. + self._graceful_disconnect() def _reset_stream(self) -> None: self._stream_id = None @@ -800,33 +827,56 @@ def _on_data_received( self, _connection: Connection, _signal_name: str, data: str ) -> None: + self._remove_connection_check_timeout() self._dispatcher.process_data(data) - self._reset_ping_timer() - - def _reset_ping_timer(self) -> None: - if self.is_websocket: - return - if not self._mode.is_client: + def _enable_connection_check(self) -> int: + def _on_check() -> bool: + if self._state == StreamState.ACTIVE: + self.check_if_connected() + return GLib.SOURCE_CONTINUE + + return GLib.timeout_add_seconds(CONNECTION_CHECK_INTERVAL, _on_check) + + def check_if_connected(self) -> None: + if self._connection_check_timeout_id is not None: + self._log.info( + "Ignore connection check request, because request is already in progress" + ) return - if self.state != StreamState.ACTIVE: + if self._state != StreamState.ACTIVE: + self._log.info( + "Ignore connection check request, because stream state is %s", + self._state, + ) return - if self._ping_source_id is not None: - self._log.info("Remove ping timer") - GLib.source_remove(self._ping_source_id) - self._ping_source_id = None + self._log.info("Start connection check") + self._connection_check_timeout_id = GLib.timeout_add_seconds( + CONNECTION_CHECK_TIMEOUT, self._on_connection_check_timeout + ) + assert self._smacks is not None + if self._smacks.sm_supported: + self._smacks.request_ack() + else: + self.get_module("Ping").ping(self.domain, timeout=5) - self._log.info("Start ping timer") - self._ping_source_id = GLib.timeout_add_seconds(180, self._ping) + def _on_connection_check_timeout(self) -> None: + self._connection_check_timeout_id = None + if self._state == StreamState.ACTIVE: + self._log.info("Connection check timeout") + self.disconnect(immediate=True) - def _remove_ping_timer(self) -> None: - if self._ping_source_id is None: + def _remove_connection_check_timeout(self) -> None: + if self._connection_check_timeout_id is None: return - self._log.info("Remove ping timer") - GLib.source_remove(self._ping_source_id) - self._ping_source_id = None + + GLib.source_remove(self._connection_check_timeout_id) + self._connection_check_timeout_id = None + + if self._state == StreamState.ACTIVE: + self._log.info("Connection check successful") def send_stanza( self, @@ -1103,34 +1153,6 @@ StreamError.SESSION, stanza.getError(), stanza.getErrorMsg() ) - def _ping(self) -> None: - self._ping_source_id = None - self._ping_task = self.get_module("Ping").ping( - self.domain, timeout=10, callback=self._on_pong - ) - - def _on_pong(self, task: Task) -> None: - self._ping_task = None - - try: - task.finish() - except TimeoutStanzaError: - self._log.info("Ping timeout") - self._disconnect(immediate=True) - return - - except CancelledError: - return - - except StanzaError: - pass - - self._log.info("Pong") - - def _cancel_ping_task(self) -> None: - if self._ping_task is not None: - self._ping_task.cancel() - def register_handler(self, *args: Any, **kwargs: Any) -> None: self._dispatcher.register_handler(*args, **kwargs) @@ -1140,9 +1162,11 @@ def destroy(self) -> None: for task in self._tasks: task.cancel() - self._remove_ping_timer() self._smacks = None self._sasl = None self._dispatcher.cleanup() self._dispatcher = None self.remove_subscriptions() + self._remove_stream_end_timeout() + self._remove_connection_check_timeout() + GLib.source_remove(self._connection_check_id) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/dispatcher.py new/python-nbxmpp-7.4.0/nbxmpp/dispatcher.py --- old/python-nbxmpp-7.2.0/nbxmpp/dispatcher.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/dispatcher.py 2026-08-26 18:54:51.000000000 +0200 @@ -459,7 +459,13 @@ data = self.replace_non_character(data) if self._client.is_websocket: - stanza = Node(node=data) + try: + stanza = Node(node=data) + except (ExpatError, ValueError) as error: + self._log.error("XML parsing error: %s", error) + self.notify("parsing-error", str(error)) + return + if is_websocket_stream_error(stanza): for tag in stanza.getChildren(): name = tag.getName() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/account_invite.py new/python-nbxmpp-7.4.0/nbxmpp/modules/account_invite.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/account_invite.py 1970-01-01 01:00:00.000000000 +0100 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/account_invite.py 2026-08-26 18:54:51.000000000 +0200 @@ -0,0 +1,52 @@ +# Copyright (C) 2026 Philipp Hörist <philipp AT hoerist.com> +# +# This file is part of nbxmpp. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import datetime as dt + +from nbxmpp.modules.dataforms import SimpleDataForm +from nbxmpp.modules.date_and_time import parse_datetime +from nbxmpp.protocol import Node +from nbxmpp.structs import AccountInviteResult + + +def parse_account_invite(form: Node) -> AccountInviteResult: + form = SimpleDataForm(extend=form) + assert isinstance(form, SimpleDataForm) + # TODO: Check form type once its standardized + # field = form.vars.get("FORM_TYPE") + # if field is None: + # return None + + try: + uri = form["uri"].value + except Exception: + raise ValueError("uri field is missing") + + assert isinstance(uri, str) + if not uri: + raise ValueError("empty uri value") + + try: + landing_url = form["landing-url"].value or None + except Exception: + landing_url = None + + assert isinstance(landing_url, str | None) + + try: + expire = parse_datetime(form["expire"].value) + except Exception: + expire = None + + assert isinstance(expire, dt.datetime | None) + + return AccountInviteResult( + uri=uri, + landing_url=landing_url, + expire=expire, + ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/eme.py new/python-nbxmpp-7.4.0/nbxmpp/modules/eme.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/eme.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/eme.py 2026-08-26 18:54:51.000000000 +0200 @@ -40,7 +40,7 @@ if encryption is None: return - name = encryption.getAttr("name") + name = encryption.getAttr("name") or "" namespace = encryption.getAttr("namespace") if namespace is None: self._log.warning("No namespace on message") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/fallback.py new/python-nbxmpp-7.4.0/nbxmpp/modules/fallback.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/fallback.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/fallback.py 2026-08-26 18:54:51.000000000 +0200 @@ -16,6 +16,13 @@ start: int end: int + def __post_init__(self): + if self.start < 0 or self.end < 0: + raise ValueError("Range with negative numbers: %s", self) + + if self.start >= self.end: + raise ValueError("Range with start > end: %s", self) + FallbackLangMapT = dict[str | None, FallbackRange | None] FallbacksForT = dict[str, FallbackLangMapT | None] @@ -61,12 +68,8 @@ assert end is not None try: range_ = FallbackRange(start=int(start), end=int(end)) - except Exception: - log.warning("Incorrect range on fallback indication") - return - - if range_.start < 0 or range_.end < 0: - log.warning("Fallback range with negative numbers: %s", range_) + except Exception as error: + log.warning("Incorrect range on fallback indication: %s", error) return fallback_lang_map[lang] = range_ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/mds.py new/python-nbxmpp-7.4.0/nbxmpp/modules/mds.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/mds.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/mds.py 2026-08-26 18:54:51.000000000 +0200 @@ -32,7 +32,7 @@ XEP-0490 """ - _depends = {"publish": "PubSub"} + _depends = {"publish": "PubSub", "request_items": "PubSub"} def __init__(self, client: Client) -> None: BaseModule.__init__(self, client) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/omemo.py new/python-nbxmpp-7.4.0/nbxmpp/modules/omemo.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/omemo.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/omemo.py 2026-08-26 18:54:51.000000000 +0200 @@ -275,7 +275,9 @@ raise MalformedStanzaError("No bundle node found", item) result = {} - signed_prekey_node = bundle.getTag("signedPreKeyPublic") + signed_prekey_node = bundle.getTag( + "signedPreKeyPublic", namespace=Namespace.OMEMO_TEMP + ) try: result["spk"] = {"key": b64decode(signed_prekey_node.getData())} except Exception as error: @@ -288,26 +290,32 @@ except Exception as error: raise MalformedStanzaError("Invalid signedPreKeyId: %s" % error, item) - signed_signature_node = bundle.getTag("signedPreKeySignature") + signed_signature_node = bundle.getTag( + "signedPreKeySignature", namespace=Namespace.OMEMO_TEMP + ) try: result["spk_signature"] = b64decode(signed_signature_node.getData()) except Exception as error: error = "Failed to decode signedPreKeySignature: %s" % error raise MalformedStanzaError(error, item) - identity_key_node = bundle.getTag("identityKey") + identity_key_node = bundle.getTag("identityKey", namespace=Namespace.OMEMO_TEMP) try: result["ik"] = b64decode(identity_key_node.getData()) except Exception as error: error = "Failed to decode IdentityKey: %s" % error raise MalformedStanzaError(error, item) - prekeys = bundle.getTag("prekeys") - if prekeys is None or not prekeys.getChildren(): + prekeys_node = bundle.getTag("prekeys", namespace=Namespace.OMEMO_TEMP) + if prekeys_node is None: raise MalformedStanzaError("No prekeys node found", item) + prekeys = prekeys_node.getTags("preKeyPublic", namespace=Namespace.OMEMO_TEMP) + if not prekeys: + raise MalformedStanzaError("No prekeys found", item) + result["otpks"] = [] - for prekey in prekeys.getChildren(): + for prekey in prekeys: try: id_ = int(prekey.getAttr("preKeyId")) except Exception as error: @@ -400,15 +408,16 @@ if list_node is None: raise MalformedStanzaError("No list node found", item) - if not list_node.getChildren(): - return [] - result: list[int] = [] - devices_nodes = list_node.getChildren() - for dn in devices_nodes: + for dn in list_node.getTags("device", namespace=Namespace.OMEMO_TEMP): _id = dn.getAttr("id") - if _id: + if _id is None: + raise MalformedStanzaError("Device node without id attribute", item) + + try: result.append(int(_id)) + except Exception: + raise MalformedStanzaError("Non integer device id", item) return result diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/openpgp.py new/python-nbxmpp-7.4.0/nbxmpp/modules/openpgp.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/openpgp.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/openpgp.py 2026-08-26 18:54:51.000000000 +0200 @@ -9,7 +9,7 @@ from typing import Any from typing import TYPE_CHECKING -import random +import secrets import string import time @@ -344,12 +344,12 @@ def get_rpad() -> str: - rpad_range = random.randint(30, 50) - return "".join(random.choice(string.ascii_letters) for _ in range(rpad_range)) + rpad_range = secrets.randbelow(21) + 30 + return "".join(secrets.choice(string.ascii_letters) for _ in range(rpad_range)) def create_message_stanza( - stanza: Message, encrypted_payload: str | bytes, with_fallback_text: bool + stanza: Message, encrypted_payload: str | bytes, with_eme: bool ) -> None: b64encoded_payload = b64encode(encrypted_payload) @@ -357,13 +357,11 @@ openpgp_node.addData(b64encoded_payload) stanza.addChild(node=openpgp_node) - eme_node = Node( - "encryption", attrs={"xmlns": Namespace.EME, "namespace": Namespace.OPENPGP} - ) - stanza.addChild(node=eme_node) - - if with_fallback_text: - stanza.setBody("This message is *encrypted* with OpenPGP") + if with_eme: + eme_node = Node( + "encryption", attrs={"xmlns": Namespace.EME, "namespace": Namespace.OPENPGP} + ) + stanza.addChild(node=eme_node) def _make_keylist(keylist: list[PGPKeyMetadata] | None) -> Node: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/vcard_avatar.py new/python-nbxmpp-7.4.0/nbxmpp/modules/vcard_avatar.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/vcard_avatar.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/vcard_avatar.py 2026-08-26 18:54:51.000000000 +0200 @@ -15,6 +15,7 @@ from nbxmpp.protocol import Presence from nbxmpp.structs import PresenceProperties from nbxmpp.structs import StanzaHandler +from nbxmpp.util import normalize_sha1 if TYPE_CHECKING: from nbxmpp.client import Client @@ -47,15 +48,27 @@ avatar_sha = update.getTagData("photo") if avatar_sha is None: properties.avatar_state = AvatarState.NOT_READY - self._log.info("%s is not ready to promote an avatar", stanza.getFrom()) + self._log.info("%s is not ready to promote an avatar", properties.jid) # Empty update element, ignore return if avatar_sha == "": properties.avatar_state = AvatarState.EMPTY - self._log.info("%s empty avatar advertised", stanza.getFrom()) + self._log.info("%s empty avatar advertised", properties.jid) + return + + # XEP-0153 hashes are hex SHA-1 of the image bytes. Implementations must + # accept mixed case and should emit lowercase. + normalized_sha = normalize_sha1(avatar_sha) + if normalized_sha is None: + properties.avatar_state = AvatarState.IGNORE + self._log.warning( + "%s advertised invalid avatar hash: %r", + properties.jid, + avatar_sha, + ) return properties.avatar_sha = avatar_sha properties.avatar_state = AvatarState.ADVERTISED - self._log.info("%s advertises %s", stanza.getFrom(), avatar_sha) + self._log.info("%s advertises %s", properties.jid, avatar_sha) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/modules/vcard_temp.py new/python-nbxmpp-7.4.0/nbxmpp/modules/vcard_temp.py --- old/python-nbxmpp-7.2.0/nbxmpp/modules/vcard_temp.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/modules/vcard_temp.py 2026-08-26 18:54:51.000000000 +0200 @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING import hashlib +import logging from dataclasses import dataclass from dataclasses import field @@ -28,6 +29,9 @@ from nbxmpp.client import Client +log = logging.getLogger("nbxmpp.m.vcardtemp") + + class VCardTemp(BaseModule): def __init__(self, client: Client) -> None: BaseModule.__init__(self, client) @@ -135,6 +139,11 @@ if not avatar: return None, None - encoded_avatar = b64decode(avatar) - avatar_sha = hashlib.sha1(encoded_avatar).hexdigest() - return encoded_avatar, avatar_sha + try: + decoded_avatar = b64decode(avatar) + except Exception as error: + log.warning("Unable to decode avatar: %s", error) + return None, None + + avatar_sha = hashlib.sha1(decoded_avatar).hexdigest() + return decoded_avatar, avatar_sha diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/protocol.py new/python-nbxmpp-7.4.0/nbxmpp/protocol.py --- old/python-nbxmpp-7.2.0/nbxmpp/protocol.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/protocol.py 2026-08-26 18:54:51.000000000 +0200 @@ -1054,7 +1054,7 @@ def isResult(self) -> bool: return self.getAttr("type") == "result" - def getTo(self): + def getTo(self) -> JID | None: """ Return value of the 'to' attribute """ @@ -1064,7 +1064,7 @@ pass return None - def getFrom(self): + def getFrom(self) -> JID | None: """ Return value of the 'from' attribute """ @@ -1270,7 +1270,7 @@ def __init__( self, - to: str | None = None, + to: JID | str | None = None, body: str | None = None, xhtml: str | None = None, typ: Literal["chat", "groupchat", "normal", "headline", "error"] | None = None, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/smacks.py new/python-nbxmpp-7.4.0/nbxmpp/smacks.py --- old/python-nbxmpp-7.2.0/nbxmpp/smacks.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/smacks.py 2026-08-26 18:54:51.000000000 +0200 @@ -159,7 +159,7 @@ self._out_h += 1 if len(self._uqueue) > self.max_queue: - self._request_ack() + self.request_ack() # Send an ack after 100 unacked messages if (self._in_h - self._acked_h) > 100: self._send_ack() @@ -244,7 +244,7 @@ self._log.info("Close session") self._reset_state() - def _request_ack(self) -> None: + def request_ack(self) -> None: request = Node(Namespace.STREAM_MGMT + " r") self._log.debug("Request ack") self._client.send_nonza(request, now=False) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/structs.py new/python-nbxmpp-7.4.0/nbxmpp/structs.py --- old/python-nbxmpp-7.2.0/nbxmpp/structs.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/structs.py 2026-08-26 18:54:51.000000000 +0200 @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING import logging -import random +import secrets import time from collections.abc import Sequence from dataclasses import dataclass @@ -235,7 +235,7 @@ sid: int iv: bytes keys: dict[int, tuple[bytes, bool]] - payload: bytes + payload: bytes | None class AnnotationNote(NamedTuple): @@ -826,7 +826,7 @@ namespace: str = Namespace.OMEMO_TEMP def pick_prekey(self) -> dict[str, str]: - return random.SystemRandom().choice(self.otpks) + return secrets.choice(self.otpks) class ChatMarker(NamedTuple): @@ -1532,3 +1532,10 @@ except exceptions.FallbackLanguageError: log.warning("Missing fallback for language: %s", lang) return text + + +@dataclass +class AccountInviteResult: + uri: str + landing_url: str | None + expire: datetime | None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/tcp.py new/python-nbxmpp-7.4.0/nbxmpp/tcp.py --- old/python-nbxmpp-7.2.0/nbxmpp/tcp.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/tcp.py 2026-08-26 18:54:51.000000000 +0200 @@ -53,8 +53,6 @@ self._input_closed = False self._output_closed = False - self._keepalive_id: int | None = None - def connect(self) -> None: self.state = TCPState.CONNECTING @@ -158,25 +156,6 @@ self.notify("connected") self._read_async() - def _remove_keepalive_timer(self) -> None: - if self._keepalive_id is not None: - self._log.info("Remove keepalive timer") - GLib.source_remove(self._keepalive_id) - self._keepalive_id = None - - def _renew_keepalive_timer(self) -> None: - if self._con is None: - return - self._remove_keepalive_timer() - self._log.info("Add keepalive timer") - self._keepalive_id = GLib.timeout_add_seconds(5, self._send_keepalive) - - def _send_keepalive(self) -> None: - self._log.info("Send keepalive") - self._keepalive_id = None - if not self._con.get_output_stream().has_pending(): - self._write_all_async(b" ") - def start_tls_negotiation(self) -> None: self._log.info("Start TLS negotiation") self._tls_handshake_in_progress = True @@ -254,8 +233,6 @@ self._finalize("disconnected") return - self._renew_keepalive_timer() - self._read_buffer += data try: @@ -321,16 +298,11 @@ decoded_data = data.decode() self._log_stanza(decoded_data, received=False) - if decoded_data == " ": - # keepalive whitespace - self._renew_keepalive_timer() - - else: - for stanza in self._write_stanza_buffer: - try: - self.notify("data-sent", stanza) - except Exception: - self._log.exception("Error while executing data-sent:") + for stanza in self._write_stanza_buffer: + try: + self.notify("data-sent", stanza) + except Exception: + self._log.exception("Error while executing data-sent:") if self._output_closed and not self._write_queue: self._check_for_shutdown() @@ -353,7 +325,6 @@ self._write_stanzas() def disconnect(self) -> None: - self._remove_keepalive_timer() if self.state == TCPState.CONNECTING: self.state = TCPState.DISCONNECTING self._connect_cancellable.cancel() @@ -371,20 +342,17 @@ self._finalize("disconnected") def shutdown_input(self) -> None: - self._remove_keepalive_timer() self._log.info("Shutdown input") self._input_closed = True self._read_cancellable.cancel() self._check_for_shutdown() def shutdown_output(self) -> None: - self._remove_keepalive_timer() self.state = TCPState.DISCONNECTING self._log.info("Shutdown output") self._output_closed = True def _finalize(self, signal_name: str) -> None: - self._remove_keepalive_timer() if self._con is not None: try: self._con.get_socket().shutdown(True, True) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/third_party/hsluv.py new/python-nbxmpp-7.4.0/nbxmpp/third_party/hsluv.py --- old/python-nbxmpp-7.2.0/nbxmpp/third_party/hsluv.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/third_party/hsluv.py 2026-08-26 18:54:51.000000000 +0200 @@ -6,51 +6,63 @@ import math -m = [ +ColorT = tuple[float, float, float] + +# XYZ-to-sRGB matrix +_m = [ [3.240969941904521, -1.537383177570093, -0.498610760293], [-0.96924363628087, 1.87596750150772, 0.041555057407175], [0.055630079696993, -0.20397695888897, 1.056971514242878], ] -minv = [ +# sRGB-to-XYZ matrix +_m_inv = [ [0.41239079926595, 0.35758433938387, 0.18048078840183], [0.21263900587151, 0.71516867876775, 0.072192315360733], [0.019330818715591, 0.11919477979462, 0.95053215224966], ] -REF_Y = 1.0 -REF_U = 0.19783000664283 -REF_V = 0.46831999493879 -KAPPA = 903.2962962 -EPSILON = 0.0088564516 -HEX_CHARS = "0123456789abcdef" +_ref_y = 1.0 +_ref_u = 0.19783000664283 +_ref_v = 0.46831999493879 +_kappa = 903.2962962 # 24389/27 == (29/3)**3 +_epsilon = 0.0088564516 # 216/24389 == (6/29)**3 + + +def _normalize_output(_hx_tuple: ColorT) -> ColorT: + # as in snapshot rev 4, the tolerance should be 1e-11 + return ( + round(_hx_tuple[0], ndigits=11 - 1), + round(_hx_tuple[1], ndigits=11 - 1), + round(_hx_tuple[2], ndigits=11 - 1), + ) -def _distance_line_from_origin(line): - v = math.pow(line["slope"], 2) + 1 - return math.fabs(line["intercept"]) / math.sqrt(v) +def _distance_line_from_origin(line: dict[str, float]) -> float: + v = line["slope"] ** 2 + 1 + return abs(line["intercept"]) / math.sqrt(v) -def _length_of_ray_until_intersect(theta, line): +def _length_of_ray_until_intersect(theta: float, line: dict[str, float]) -> float: return line["intercept"] / (math.sin(theta) - line["slope"] * math.cos(theta)) -def _get_bounds(l): - result = [] - sub1 = math.pow(l + 16, 3) / 1560896 - if sub1 > EPSILON: +def _get_bounds(l: float) -> list[dict[str, float]]: + result: list[dict[str, float]] = [] + sub1 = ((l + 16) ** 3) / 1560896 + if sub1 > _epsilon: sub2 = sub1 else: - sub2 = l / KAPPA + sub2 = l / _kappa _g = 0 while _g < 3: c = _g - _g = _g + 1 - m1 = m[c][0] - m2 = m[c][1] - m3 = m[c][2] + _g += 1 + m1 = _m[c][0] + m2 = _m[c][1] + m3 = _m[c][2] _g1 = 0 while _g1 < 2: t = _g1 - _g1 = _g1 + 1 + _g1 += 1 top1 = (284517 * m1 - 94839 * m3) * sub2 top2 = (838422 * m3 + 769860 * m2 + 731718 * m1) * l * sub2 - ( 769860 * t @@ -60,282 +72,237 @@ return result -def _max_safe_chroma_for_l(l): - bounds = _get_bounds(l) - _hx_min = 1.7976931348623157e308 - _g = 0 - while _g < 2: - i = _g - _g = _g + 1 - length = _distance_line_from_origin(bounds[i]) - if math.isnan(_hx_min): - pass - elif math.isnan(length): - _hx_min = length - else: - _hx_min = min(_hx_min, length) - return _hx_min - - -def _max_chroma_for_lh(l, h): - hrad = h / 360 * math.pi * 2 - bounds = _get_bounds(l) - _hx_min = 1.7976931348623157e308 - _g = 0 - while _g < len(bounds): - bound = bounds[_g] - _g = _g + 1 - length = _length_of_ray_until_intersect(hrad, bound) - if length >= 0: - if math.isnan(_hx_min): - pass - elif math.isnan(length): - _hx_min = length - else: - _hx_min = min(_hx_min, length) - return _hx_min - - -def _dot_product(a, b): - sum_ = 0 - _g1 = 0 - _g = len(a) - while _g1 < _g: - i = _g1 - _g1 = _g1 + 1 - sum_ += a[i] * b[i] - return sum_ +def _max_safe_chroma_for_l(l: float) -> float: + return min(_distance_line_from_origin(bound) for bound in _get_bounds(l)) + + +def _max_chroma_for_lh(l: float, h: float) -> float: + hrad = math.radians(h) + lengths = [_length_of_ray_until_intersect(hrad, bound) for bound in _get_bounds(l)] + return min(length for length in lengths if length >= 0) + + +def _dot_product(a: list[float], b: ColorT) -> float: + return sum(i * j for i, j in zip(a, b, strict=True)) -def _from_linear(c): +def _from_linear(c: float) -> float: if c <= 0.0031308: return 12.92 * c - return 1.055 * math.pow(c, 0.416666666666666685) - 0.055 + return 1.055 * math.pow(c, 5 / 12) - 0.055 -def _to_linear(c): + +def _to_linear(c: float) -> float: if c > 0.04045: return math.pow((c + 0.055) / 1.055, 2.4) + return c / 12.92 -def xyz_to_rgb(_hx_tuple): - return [ - _from_linear(_dot_product(m[0], _hx_tuple)), - _from_linear(_dot_product(m[1], _hx_tuple)), - _from_linear(_dot_product(m[2], _hx_tuple)), - ] +def _y_to_l(y: float) -> float: + if y <= _epsilon: + return y / _ref_y * _kappa + return 116 * math.pow(y / _ref_y, 1 / 3) - 16 -def rgb_to_xyz(_hx_tuple): - rgbl = [ - _to_linear(_hx_tuple[0]), - _to_linear(_hx_tuple[1]), - _to_linear(_hx_tuple[2]), - ] - return [ - _dot_product(minv[0], rgbl), - _dot_product(minv[1], rgbl), - _dot_product(minv[2], rgbl), - ] +def _l_to_y(l: float) -> float: + if l <= 8: + return _ref_y * l / _kappa -def _y_to_l(y): - if y <= EPSILON: - return y / REF_Y * KAPPA - return 116 * math.pow(y / REF_Y, 0.333333333333333315) - 16 + return _ref_y * (((l + 16) / 116) ** 3) -def _l_to_y(l): - if l <= 8: - return REF_Y * l / KAPPA - return REF_Y * math.pow((l + 16) / 116, 3) +def xyz_to_rgb(_hx_tuple: ColorT) -> ColorT: + return ( + _from_linear(_dot_product(_m[0], _hx_tuple)), + _from_linear(_dot_product(_m[1], _hx_tuple)), + _from_linear(_dot_product(_m[2], _hx_tuple)), + ) -def xyz_to_luv(_hx_tuple): +def rgb_to_xyz(_hx_tuple: ColorT) -> ColorT: + rgbl = ( + _to_linear(_hx_tuple[0]), + _to_linear(_hx_tuple[1]), + _to_linear(_hx_tuple[2]), + ) + return ( + _dot_product(_m_inv[0], rgbl), + _dot_product(_m_inv[1], rgbl), + _dot_product(_m_inv[2], rgbl), + ) + + +def xyz_to_luv(_hx_tuple: ColorT) -> ColorT: x = float(_hx_tuple[0]) y = float(_hx_tuple[1]) z = float(_hx_tuple[2]) - divider = x + 15 * y + 3 * z - var_u = 4 * x - var_v = 9 * y - if divider != 0: - var_u = var_u / divider - var_v = var_v / divider - else: - var_u = float("nan") - var_v = float("nan") l = _y_to_l(y) if l == 0: - return [0, 0, 0] - u = 13 * l * (var_u - REF_U) - v = 13 * l * (var_v - REF_V) - return [l, u, v] + return (0, 0, 0) + divider = x + 15 * y + 3 * z + if divider == 0: + u = v = float("nan") + return (l, u, v) + var_u = 4 * x / divider + var_v = 9 * y / divider + u = 13 * l * (var_u - _ref_u) + v = 13 * l * (var_v - _ref_v) + return (l, u, v) -def luv_to_xyz(_hx_tuple): +def luv_to_xyz(_hx_tuple: ColorT) -> ColorT: l = float(_hx_tuple[0]) u = float(_hx_tuple[1]) v = float(_hx_tuple[2]) if l == 0: - return [0, 0, 0] - var_u = u / (13 * l) + REF_U - var_v = v / (13 * l) + REF_V + return (0, 0, 0) + var_u = u / (13 * l) + _ref_u + var_v = v / (13 * l) + _ref_v y = _l_to_y(l) - x = 0 - ((9 * y * var_u) / (((var_u - 4) * var_v) - var_u * var_v)) - z = (((9 * y) - (15 * var_v * y)) - (var_v * x)) / (3 * var_v) - return [x, y, z] + x = y * 9 * var_u / (4 * var_v) + z = y * (12 - 3 * var_u - 20 * var_v) / (4 * var_v) + return (x, y, z) -def luv_to_lch(_hx_tuple): +def luv_to_lch(_hx_tuple: ColorT) -> ColorT: l = float(_hx_tuple[0]) u = float(_hx_tuple[1]) v = float(_hx_tuple[2]) - _v = (u * u) + (v * v) - if _v < 0: - c = float("nan") - else: - c = math.sqrt(_v) - if c < 0.00000001: + c = math.hypot(u, v) + if c < 1e-08: h = 0 else: hrad = math.atan2(v, u) - h = hrad * 180.0 / 3.1415926535897932 + h = math.degrees(hrad) if h < 0: - h = 360 + h - return [l, c, h] + h += 360 + return (l, c, h) -def lch_to_luv(_hx_tuple): +def lch_to_luv(_hx_tuple: ColorT) -> ColorT: l = float(_hx_tuple[0]) c = float(_hx_tuple[1]) h = float(_hx_tuple[2]) - hrad = h / 360.0 * 2 * math.pi + hrad = math.radians(h) u = math.cos(hrad) * c v = math.sin(hrad) * c - return [l, u, v] + return (l, u, v) -def hsluv_to_lch(_hx_tuple): +def hsluv_to_lch(_hx_tuple: ColorT) -> ColorT: h = float(_hx_tuple[0]) s = float(_hx_tuple[1]) l = float(_hx_tuple[2]) - if l > 99.9999999: - return [100, 0, h] - if l < 0.00000001: - return [0, 0, h] + if l > 100 - 1e-7: + return (100, 0, h) + if l < 1e-08: + return (0, 0, h) _hx_max = _max_chroma_for_lh(l, h) c = _hx_max / 100 * s - return [l, c, h] + return (l, c, h) -def lch_to_hsluv(_hx_tuple): +def lch_to_hsluv(_hx_tuple: ColorT) -> ColorT: l = float(_hx_tuple[0]) c = float(_hx_tuple[1]) h = float(_hx_tuple[2]) - if l > 99.9999999: - return [h, 0, 100] - if l < 0.00000001: - return [h, 0, 0] + if l > 100 - 1e-7: + return (h, 0, 100) + if l < 1e-08: + return (h, 0, 0) _hx_max = _max_chroma_for_lh(l, h) s = c / _hx_max * 100 - return [h, s, l] + return (h, s, l) -def hpluv_to_lch(_hx_tuple): +def hpluv_to_lch(_hx_tuple: ColorT) -> ColorT: h = float(_hx_tuple[0]) s = float(_hx_tuple[1]) l = float(_hx_tuple[2]) - if l > 99.9999999: - return [100, 0, h] - if l < 0.00000001: - return [0, 0, h] + if l > 100 - 1e-7: + return (100, 0, h) + if l < 1e-08: + return (0, 0, h) _hx_max = _max_safe_chroma_for_l(l) c = _hx_max / 100 * s - return [l, c, h] + return (l, c, h) -def lch_to_hpluv(_hx_tuple): +def lch_to_hpluv(_hx_tuple: ColorT) -> ColorT: l = float(_hx_tuple[0]) c = float(_hx_tuple[1]) h = float(_hx_tuple[2]) - if l > 99.9999999: - return [h, 0, 100] - if l < 0.00000001: - return [h, 0, 0] + if l > 100 - 1e-7: + return (h, 0, 100) + if l < 1e-08: + return (h, 0, 0) _hx_max = _max_safe_chroma_for_l(l) s = c / _hx_max * 100 - return [h, s, l] + return (h, s, l) -def rgb_to_hex(_hx_tuple): - h = "#" - _g = 0 - while _g < 3: - i = _g - _g = _g + 1 - chan = float(_hx_tuple[i]) - c = math.floor(chan * 255 + 0.5) - digit2 = int(c % 16) - digit1 = int((c - digit2) / 16) - - h += HEX_CHARS[digit1] + HEX_CHARS[digit2] - return h +def rgb_to_hex(_hx_tuple: ColorT) -> str: + return "#{:02x}{:02x}{:02x}".format( + math.floor(_hx_tuple[0] * 255 + 0.5), + math.floor(_hx_tuple[1] * 255 + 0.5), + math.floor(_hx_tuple[2] * 255 + 0.5), + ) -def hex_to_rgb(hex_): - hex_ = hex_.lower() - ret = [] - _g = 0 - while _g < 3: - i = _g - _g = _g + 1 - index = i * 2 + 1 - _hx_str = hex_[index] - digit1 = HEX_CHARS.find(_hx_str) - index1 = i * 2 + 2 - str1 = hex_[index1] - digit2 = HEX_CHARS.find(str1) - n = digit1 * 16 + digit2 - ret.append(n / 255.0) - return ret +def hex_to_rgb(_hex: str) -> ColorT: + # skip leading '#' + r = int(_hex[1:3], base=16) / 255.0 + g = int(_hex[3:5], base=16) / 255.0 + b = int(_hex[5:7], base=16) / 255.0 + return (r, g, b) -def lch_to_rgb(_hx_tuple): +def lch_to_rgb(_hx_tuple: ColorT) -> ColorT: return xyz_to_rgb(luv_to_xyz(lch_to_luv(_hx_tuple))) -def rgb_to_lch(_hx_tuple): +def rgb_to_lch(_hx_tuple: ColorT) -> ColorT: return luv_to_lch(xyz_to_luv(rgb_to_xyz(_hx_tuple))) -def hsluv_to_rgb(_hx_tuple): +def _hsluv_to_rgb(_hx_tuple: ColorT) -> ColorT: return lch_to_rgb(hsluv_to_lch(_hx_tuple)) -def rgb_to_hsluv(_hx_tuple): +def hsluv_to_rgb(_hx_tuple: ColorT) -> ColorT: + return _normalize_output(_hsluv_to_rgb(_hx_tuple)) + + +def rgb_to_hsluv(_hx_tuple: ColorT) -> ColorT: return lch_to_hsluv(rgb_to_lch(_hx_tuple)) -def hpluv_to_rgb(_hx_tuple): +def _hpluv_to_rgb(_hx_tuple: ColorT) -> ColorT: return lch_to_rgb(hpluv_to_lch(_hx_tuple)) -def rgb_to_hpluv(_hx_tuple): +def hpluv_to_rgb(_hx_tuple: ColorT) -> ColorT: + return _normalize_output(_hpluv_to_rgb(_hx_tuple)) + + +def rgb_to_hpluv(_hx_tuple: ColorT) -> ColorT: return lch_to_hpluv(rgb_to_lch(_hx_tuple)) -def hsluv_to_hex(_hx_tuple): +def hsluv_to_hex(_hx_tuple: ColorT) -> str: return rgb_to_hex(hsluv_to_rgb(_hx_tuple)) -def hpluv_to_hex(_hx_tuple): +def hpluv_to_hex(_hx_tuple: ColorT) -> str: return rgb_to_hex(hpluv_to_rgb(_hx_tuple)) -def hex_to_hsluv(s): +def hex_to_hsluv(s: str) -> ColorT: return rgb_to_hsluv(hex_to_rgb(s)) -def hex_to_hpluv(s): +def hex_to_hpluv(s: str) -> ColorT: return rgb_to_hpluv(hex_to_rgb(s)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/nbxmpp/util.py new/python-nbxmpp-7.4.0/nbxmpp/util.py --- old/python-nbxmpp-7.2.0/nbxmpp/util.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/nbxmpp/util.py 2026-08-26 18:54:51.000000000 +0200 @@ -53,6 +53,8 @@ log = logging.getLogger("nbxmpp.util") +SHA1_RX = re.compile(r"[a-f0-9]{40}") + def b64decode(data: str | bytes) -> bytes: if not data: @@ -61,6 +63,8 @@ if isinstance(data, str): data = data.encode() + # Don't validate base64 data because in XMPP whitespace in + # base64 payloads must be accepted return base64.b64decode(data) @@ -139,7 +143,7 @@ saturation: float, lightness: float, ) -> tuple[float, float, float]: - return clip_rgb(*hsluv.hsluv_to_rgb((hue, saturation, lightness))) # type: ignore + return clip_rgb(*hsluv.hsluv_to_rgb((hue, saturation, lightness))) @lru_cache(maxsize=1024) @@ -473,3 +477,11 @@ raise ValueError("No href attr found") return href raise ValueError("no websocket uri found") + + +def normalize_sha1(value: str) -> str | None: + """Return a canonical lowercase SHA-1 hex digest, or None if invalid.""" + value = value.lower() + if SHA1_RX.fullmatch(value) is None: + return None + return value diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/pyproject.toml new/python-nbxmpp-7.4.0/pyproject.toml --- old/python-nbxmpp-7.2.0/pyproject.toml 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/pyproject.toml 2026-08-26 18:54:51.000000000 +0200 @@ -8,7 +8,7 @@ keywords = ["chat", "messaging", "im", "xmpp"] readme = "README.md" requires-python = ">=3.10" -license = {text = "GPL-3.0-or-later"} +license = "GPL-3.0-or-later" authors = [ {name = "Philipp Hörist", email = "[email protected]"}, {name = "Yann Leboulanger", email = "[email protected]"}, @@ -219,7 +219,6 @@ "S112", # try`-`except`-`continue` "S303", # Use of insecure MD2, MD4, MD5, or SHA1 hash function "S310", # Audit URL open for permitted schemes - "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes "S314", # Using `xml` to parse untrusted data is known to be vulnerable to XML attacks; use `defusedxml` equivalents "S324", # Probable use of insecure hash functions "S608", # Possible SQL injection vector through string-based query construction diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/test/unit/test_fallback.py new/python-nbxmpp-7.4.0/test/unit/test_fallback.py --- old/python-nbxmpp-7.2.0/test/unit/test_fallback.py 2026-04-19 19:12:23.000000000 +0200 +++ new/python-nbxmpp-7.4.0/test/unit/test_fallback.py 2026-08-26 18:54:51.000000000 +0200 @@ -64,6 +64,20 @@ self.assertIsNone(fallbacks_for) + xml = """ + <message to='[email protected]' id='message-id2' type='groupchat'> + <body>> Anna wrote:\n> Hi, how are you?\nGreat</body> + <fallback xmlns='urn:xmpp:fallback:0' for='urn:xmpp:test:0'> + <body start='10' end='10' /> + </fallback> + </message> + """ + + message = nbxmpp.Message(node=xml) + fallbacks_for = parse_fallback_indication(log, message) + + self.assertIsNone(fallbacks_for) + def test_strip_fallback(self): fallbacks_for: FallbacksForT = { "urn:xmpp:test:1": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/python-nbxmpp-7.2.0/test/unit/test_omemo.py new/python-nbxmpp-7.4.0/test/unit/test_omemo.py --- old/python-nbxmpp-7.2.0/test/unit/test_omemo.py 1970-01-01 01:00:00.000000000 +0100 +++ new/python-nbxmpp-7.4.0/test/unit/test_omemo.py 2026-08-26 18:54:51.000000000 +0200 @@ -0,0 +1,40 @@ +import unittest + +from nbxmpp.modules.omemo import _parse_bundle +from nbxmpp.modules.omemo import _parse_devicelist +from nbxmpp.simplexml import Node + + +class OMEMOTest(unittest.TestCase): + + def test_parsing(self): + + devicelist = """ + <item id="current"> + <list xmlns="eu.siacs.conversations.axolotl"> + <device id="912561474" /> + <device id="532656838" /> + <device id="1" xmlns="test.namespace" /> + </list> + </item>""" + + devices = _parse_devicelist(Node(node=devicelist)) + self.assertEqual(devices, [912561474, 532656838]) + + bundle = """ + <item id='current'> + <bundle xmlns='eu.siacs.conversations.axolotl'> + <signedPreKeyPublic signedPreKeyId='1'>dGVzdHBheWxvYWQ=</signedPreKeyPublic> + <signedPreKeySignature>dGVzdHBheWxvYWQ=</signedPreKeySignature> + <identityKey>dGVzdHBheWxvYWQ=</identityKey> + <prekeys> + <preKeyPublic preKeyId='1'>dGVzdHBheWxvYWQ=</preKeyPublic> + <preKeyPublic preKeyId='2'>dGVzdHBheWxvYWQ=</preKeyPublic> + <preKeyPublic preKeyId='3'>dGVzdHBheWxvYWQ=</preKeyPublic> + <preKeyPublic preKeyId='3' xmlns="test.namespace">dGVzdHBheWxvYWQ=</preKeyPublic> + </prekeys> + </bundle> + </item>""" + + omemo_bundle = _parse_bundle(Node(node=bundle), 1) + self.assertEqual(len(omemo_bundle.otpks), 3)
