485 lines
17 KiB
Python
485 lines
17 KiB
Python
import os
|
|
import random
|
|
from contextlib import ExitStack
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import LXMF
|
|
import pytest
|
|
import RNS
|
|
from hypothesis import HealthCheck, given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from meshchatx.meshchat import ReticulumMeshChat
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir(tmp_path):
|
|
return str(tmp_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_app(temp_dir):
|
|
real_identity_class = RNS.Identity
|
|
|
|
class MockIdentityClass(real_identity_class):
|
|
def __init__(self, *args, **kwargs):
|
|
self.hash = b"test_hash_32_bytes_long_01234567"
|
|
self.hexhash = self.hash.hex()
|
|
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.ConfigManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
|
)
|
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
|
)
|
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
|
)
|
|
stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
|
stack.enter_context(patch("LXMF.LXMRouter"))
|
|
stack.enter_context(patch("LXST.Primitives.Telephony"))
|
|
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
|
|
mock_reticulum_class = stack.enter_context(patch("RNS.Reticulum"))
|
|
mock_reticulum_class.MTU = 1200
|
|
mock_reticulum_class.return_value.MTU = 1200
|
|
mock_transport_class = stack.enter_context(patch("RNS.Transport"))
|
|
mock_transport_class.MTU = 1200
|
|
mock_transport_class.return_value.MTU = 1200
|
|
stack.enter_context(patch("threading.Thread"))
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"announce_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"announce_sync_propagation_nodes",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"crawler_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"auto_backup_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
|
|
mock_id = MockIdentityClass()
|
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
|
stack.enter_context(
|
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
|
)
|
|
|
|
app = ReticulumMeshChat(
|
|
identity=mock_id,
|
|
storage_dir=temp_dir,
|
|
reticulum_config_dir=temp_dir,
|
|
)
|
|
app.active_downloads = {}
|
|
app.download_id_counter = 0
|
|
app.database = MagicMock()
|
|
|
|
# Setup basic config mocks
|
|
app.config = MagicMock()
|
|
for attr in [
|
|
"display_name",
|
|
"theme",
|
|
"language",
|
|
"voicemail_greeting",
|
|
"map_default_lat",
|
|
"map_default_lon",
|
|
]:
|
|
getattr(app.config, attr).get.return_value = "test"
|
|
for attr in ["auto_announce_enabled", "voicemail_enabled"]:
|
|
getattr(app.config, attr).get.return_value = False
|
|
app.config.lxmf_inbound_stamp_cost.get.return_value = 8
|
|
|
|
return app
|
|
|
|
|
|
# WebSocket API Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
data=st.recursive(
|
|
st.one_of(st.none(), st.booleans(), st.floats(), st.text(), st.integers()),
|
|
lambda children: st.one_of(
|
|
st.lists(children),
|
|
st.dictionaries(st.text(), children),
|
|
),
|
|
),
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_api_recursive_fuzzing(mock_app, data):
|
|
"""Fuzz the websocket API with recursive random data structures."""
|
|
mock_client = MagicMock()
|
|
mock_client.send_str = MagicMock(side_effect=lambda d: None)
|
|
|
|
# We must ensure "type" is present if it's a dict, or it might fail early
|
|
# but that's also part of fuzzing - seeing if it crashes without "type".
|
|
try:
|
|
await mock_app.on_websocket_data_received(mock_client, data)
|
|
except (KeyError, TypeError, ValueError, AttributeError):
|
|
# These are expected for malformed data
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"WebSocket API crashed with recursive data: {e}")
|
|
|
|
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(msg_type=st.text(), payload=st.dictionaries(st.text(), st.text()))
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_api_type_fuzzing(mock_app, msg_type, payload):
|
|
"""Fuzz the websocket API with random types and flat dictionary payloads."""
|
|
mock_client = MagicMock()
|
|
mock_client.send_str = MagicMock(side_effect=lambda d: None)
|
|
|
|
data = {"type": msg_type}
|
|
data.update(payload)
|
|
|
|
try:
|
|
await mock_app.on_websocket_data_received(mock_client, data)
|
|
except (KeyError, TypeError, ValueError, AttributeError):
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"WebSocket API crashed with type={msg_type}: {e}")
|
|
|
|
|
|
# URI Parsing Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(uri=st.text(min_size=0, max_size=2000))
|
|
@pytest.mark.asyncio
|
|
async def test_lxm_uri_parsing_fuzzing(mock_app, uri):
|
|
"""Fuzz LXM URI ingestion logic."""
|
|
try:
|
|
# Assuming there's a method or logic that handles lxm:// URIs
|
|
if hasattr(mock_app, "ingest_lxm_uri"):
|
|
await mock_app.ingest_lxm_uri(uri)
|
|
|
|
# Also test it through the websocket interface if it exists there
|
|
mock_client = MagicMock()
|
|
await mock_app.on_websocket_data_received(
|
|
mock_client,
|
|
{"type": "lxm.ingest_uri", "uri": uri},
|
|
)
|
|
except (KeyError, TypeError, ValueError, AttributeError):
|
|
pass
|
|
except Exception as e:
|
|
# Some specific exceptions might be okay depending on implementation,
|
|
# but generic crash is not.
|
|
if "hex" not in str(e).lower(): # Ignore hex decoding errors which are common
|
|
pytest.fail(f"URI ingestion crashed with uri={uri}: {e}")
|
|
|
|
|
|
# LXMF Packet/Message Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
content=st.binary(min_size=0, max_size=5000),
|
|
title=st.text(min_size=0, max_size=500),
|
|
fields=st.dictionaries(st.integers(min_value=0, max_value=255), st.binary()),
|
|
)
|
|
def test_lxmf_message_construction_fuzzing(mock_app, content, title, fields):
|
|
"""Fuzz LXMF message construction and field handling."""
|
|
try:
|
|
# Mock a destination
|
|
dest = MagicMock()
|
|
dest.hash = os.urandom(16)
|
|
|
|
# Test field assignment
|
|
lxmf_msg = LXMF.LXMessage(dest, mock_app.identity, content, title=title)
|
|
for k, v in fields.items():
|
|
lxmf_msg.fields[k] = v
|
|
|
|
# Test encoding/decoding if possible
|
|
lxmf_msg.pack()
|
|
# LXMF.LXMessage.unpack(packed) # Might need more mocks
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Database Migration/Data Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
table_name=st.sampled_from(["messages", "announces", "identities", "config"]),
|
|
data=st.dictionaries(
|
|
st.text(),
|
|
st.one_of(st.text(), st.integers(), st.binary(), st.none()),
|
|
),
|
|
)
|
|
def test_database_record_fuzzing(mock_app, table_name, data):
|
|
"""Fuzz database record insertion logic (simulated)."""
|
|
# This tests the DAO layer's resilience to weird data types if they aren't properly sanitized
|
|
try:
|
|
dao = None
|
|
if table_name == "messages" and hasattr(mock_app.database, "messages"):
|
|
dao = mock_app.database.messages
|
|
elif table_name == "announces" and hasattr(mock_app.database, "announces"):
|
|
dao = mock_app.database.announces
|
|
|
|
if dao and hasattr(dao, "upsert"):
|
|
dao.upsert(data)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Config Update Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
config_updates=st.dictionaries(
|
|
st.sampled_from(
|
|
[
|
|
"display_name",
|
|
"auto_announce_enabled",
|
|
"theme",
|
|
"language",
|
|
"voicemail_enabled",
|
|
"voicemail_greeting",
|
|
"map_default_lat",
|
|
"map_default_lon",
|
|
"lxmf_inbound_stamp_cost",
|
|
],
|
|
),
|
|
st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
|
|
),
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_config_update_fuzzing(mock_app, config_updates):
|
|
"""Fuzz the update_config method with various types for known keys."""
|
|
try:
|
|
if hasattr(mock_app, "update_config"):
|
|
await mock_app.update_config(config_updates)
|
|
except (TypeError, ValueError, AttributeError):
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"Config update crashed: {e}")
|
|
|
|
|
|
# LXMF Paper URI Generation Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(destination_hash=st.text(), content=st.text(), title=st.text())
|
|
@pytest.mark.asyncio
|
|
async def test_lxm_generate_paper_uri_fuzzing(
|
|
mock_app,
|
|
destination_hash,
|
|
content,
|
|
title,
|
|
):
|
|
"""Fuzz lxm.generate_paper_uri WebSocket handler."""
|
|
mock_client = MagicMock()
|
|
mock_client.send_str = MagicMock(side_effect=lambda d: None)
|
|
|
|
data = {
|
|
"type": "lxm.generate_paper_uri",
|
|
"destination_hash": destination_hash,
|
|
"content": content,
|
|
"title": title,
|
|
}
|
|
|
|
try:
|
|
await mock_app.on_websocket_data_received(mock_client, data)
|
|
except (KeyError, TypeError, ValueError, AttributeError):
|
|
pass
|
|
except Exception as e:
|
|
# Ignore common hex decoding or identity not found errors
|
|
if "hex" not in str(e).lower() and "identity not found" not in str(e).lower():
|
|
pytest.fail(f"Paper URI generation crashed: {e}")
|
|
|
|
|
|
# Telemetry Packet Fuzzing (More intensive)
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(data=st.binary(min_size=0, max_size=10000))
|
|
def test_telemetry_deep_fuzzing(data):
|
|
"""Deep fuzz the telemetry unpacking logic with large random payloads."""
|
|
from meshchatx.src.backend.telemetry_utils import Telemeter
|
|
|
|
try:
|
|
Telemeter.from_packed(data)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Markdown Renderer Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(text=st.text(min_size=0, max_size=10000))
|
|
def test_markdown_renderer_fuzzing(text):
|
|
"""Fuzz the markdown to HTML renderer."""
|
|
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
|
|
|
try:
|
|
html_out = MarkdownRenderer.render(text)
|
|
assert isinstance(html_out, str)
|
|
except Exception as e:
|
|
pytest.fail(f"MarkdownRenderer crashed: {e}")
|
|
|
|
|
|
# LXMF Message Dictionary Conversion Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
content=st.binary(min_size=0, max_size=1000),
|
|
title=st.text(min_size=0, max_size=100),
|
|
fields_data=st.dictionaries(st.integers(0, 255), st.binary()),
|
|
)
|
|
def test_lxmf_to_dict_conversion_fuzzing(mock_app, content, title, fields_data):
|
|
"""Fuzz the LXMF message to dictionary conversion logic."""
|
|
from meshchatx.src.backend.lxmf_utils import convert_lxmf_message_to_dict
|
|
|
|
try:
|
|
# Create a real-ish LXMessage but fuzzed
|
|
dest = MagicMock()
|
|
dest.hash = os.urandom(16)
|
|
|
|
msg = LXMF.LXMessage(dest, mock_app.identity, content, title=title)
|
|
for k, v in fields_data.items():
|
|
msg.fields[k] = v
|
|
|
|
# Manually set some fields that might be expected
|
|
msg.hash = os.urandom(16)
|
|
msg.source_hash = os.urandom(16)
|
|
msg.destination_hash = os.urandom(16)
|
|
msg.incoming = random.choice([True, False])
|
|
msg.progress = random.random()
|
|
msg.rssi = random.randint(-120, 0)
|
|
msg.snr = random.randint(-20, 20)
|
|
msg.q = random.randint(0, 100)
|
|
msg.delivery_attempts = random.randint(0, 10)
|
|
|
|
result = convert_lxmf_message_to_dict(msg)
|
|
assert isinstance(result, dict)
|
|
except Exception:
|
|
# LXMF might throw internal errors on weird fields, which is okay as long as it doesn't crash the whole app
|
|
pass
|
|
|
|
|
|
# LXMF Delivery Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
content=st.binary(min_size=0, max_size=2000),
|
|
title=st.text(min_size=0, max_size=100),
|
|
)
|
|
def test_on_lxmf_delivery_fuzzing(mock_app, content, title):
|
|
"""Fuzz the LXMF delivery handler with various message contents."""
|
|
try:
|
|
dest = MagicMock()
|
|
dest.hash = os.urandom(16)
|
|
msg = LXMF.LXMessage(dest, mock_app.identity, content, title=title)
|
|
|
|
# Add random fields
|
|
msg.fields[LXMF.FIELD_TELEMETRY] = os.urandom(random.randint(0, 100))
|
|
|
|
# These are usually set by LXMF when receiving
|
|
msg.source_hash = os.urandom(16)
|
|
msg.hash = os.urandom(16)
|
|
msg.signature = os.urandom(64)
|
|
|
|
mock_app.on_lxmf_delivery(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Announce Ingestion Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
aspect=st.text(),
|
|
destination_hash=st.binary(min_size=0, max_size=64),
|
|
app_data=st.binary(min_size=0, max_size=1000),
|
|
)
|
|
def test_on_lxmf_announce_received_fuzzing(
|
|
mock_app,
|
|
aspect,
|
|
destination_hash,
|
|
app_data,
|
|
):
|
|
"""Fuzz the announce received handler."""
|
|
try:
|
|
announced_identity = MagicMock()
|
|
announced_identity.hash = os.urandom(32)
|
|
announced_identity.get_public_key = MagicMock(return_value=os.urandom(32))
|
|
|
|
mock_app.on_lxmf_announce_received(
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
None, # reticulum instance
|
|
app_data,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Interface Config Parser Fuzzing (More intensive)
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(config_text=st.text(min_size=0, max_size=10000))
|
|
def test_interface_config_parser_deep_fuzzing(config_text):
|
|
"""Deep fuzz the interface configuration parser."""
|
|
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
|
|
|
|
try:
|
|
InterfaceConfigParser.parse(config_text)
|
|
except Exception:
|
|
# We don't care if it fails to parse, just that it doesn't crash the process
|
|
pass
|
|
|
|
|
|
# Telemeter Roundtrip Fuzzing
|
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
|
@given(
|
|
battery=st.floats(0, 100, allow_nan=False, allow_infinity=False),
|
|
uptime=st.integers(0, 2**32 - 1),
|
|
load=st.floats(0, 100, allow_nan=False, allow_infinity=False),
|
|
temperature=st.floats(-50, 100, allow_nan=False, allow_infinity=False),
|
|
)
|
|
def test_telemeter_roundtrip_fuzzing(battery, uptime, load, temperature):
|
|
"""Fuzz telemeter packing and unpacking in a roundtrip."""
|
|
from meshchatx.src.backend.telemetry_utils import Telemeter
|
|
|
|
try:
|
|
t = Telemeter(
|
|
battery=battery,
|
|
uptime=uptime,
|
|
load=load,
|
|
temperature=temperature,
|
|
)
|
|
packed = t.pack()
|
|
unpacked = Telemeter.from_packed(packed)
|
|
assert isinstance(unpacked, Telemeter)
|
|
except Exception:
|
|
pass
|