mirror of
https://github.com/angr/angr
synced 2026-08-17 12:23:11 -04:00
211 lines
7.7 KiB
Python
211 lines
7.7 KiB
Python
# pylint:disable=no-self-use
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from angr.mcp import server
|
|
from angr.mcp.session import (
|
|
get_session_manager,
|
|
)
|
|
|
|
|
|
class TestProjectSession:
|
|
"""Tests for the ProjectSession dataclass."""
|
|
|
|
def test_has_cfg_false_initially(self, loaded_session):
|
|
"""Test that has_cfg is False when CFG not built."""
|
|
assert loaded_session.has_cfg is False
|
|
|
|
def test_has_cfg_true_after_building(self, loaded_session_with_cfg):
|
|
"""Test that has_cfg is True after CFG is built."""
|
|
assert loaded_session_with_cfg.has_cfg is True
|
|
|
|
def test_session_attributes(self, loaded_session):
|
|
"""Test that session has correct attributes."""
|
|
assert loaded_session.project_id is not None
|
|
assert len(loaded_session.project_id) == 8
|
|
# binary_path is resolved to absolute path
|
|
assert loaded_session.binary_path.endswith("1after909")
|
|
assert loaded_session.project is not None
|
|
assert loaded_session.cfg is None
|
|
|
|
|
|
class TestSessionManager:
|
|
"""Tests for the SessionManager class."""
|
|
|
|
def test_create_session(self, session_manager, binary_path):
|
|
"""Test creating a new session."""
|
|
session = session_manager.create_session(binary_path)
|
|
|
|
assert session.project_id is not None
|
|
assert session.binary_path.endswith("1after909")
|
|
assert session.project.arch.name == "AMD64"
|
|
|
|
def test_create_session_with_options(self, session_manager, binary_path):
|
|
"""Test creating a session with custom options."""
|
|
session = session_manager.create_session(
|
|
binary_path,
|
|
auto_load_libs=False,
|
|
)
|
|
|
|
assert session.project is not None
|
|
# Verify main binary is loaded
|
|
assert session.project.loader.main_object is not None
|
|
assert session.project.loader.main_object.binary_basename == "1after909"
|
|
|
|
def test_create_session_file_not_found(self, session_manager):
|
|
"""Test that creating session with invalid path raises error."""
|
|
with pytest.raises(FileNotFoundError):
|
|
session_manager.create_session("/nonexistent/binary")
|
|
|
|
def test_get_session(self, session_manager, binary_path):
|
|
"""Test retrieving an existing session."""
|
|
created = session_manager.create_session(binary_path)
|
|
retrieved = session_manager.get_session(created.project_id)
|
|
|
|
assert retrieved is created
|
|
assert retrieved.project_id == created.project_id
|
|
|
|
def test_get_session_not_found(self, session_manager):
|
|
"""Test that getting nonexistent session raises error."""
|
|
with pytest.raises(KeyError) as exc_info:
|
|
session_manager.get_session("nonexistent")
|
|
|
|
assert "nonexistent" in str(exc_info.value)
|
|
|
|
def test_list_sessions_empty(self, session_manager):
|
|
"""Test listing sessions when none exist."""
|
|
sessions = session_manager.list_sessions()
|
|
assert sessions == []
|
|
|
|
def test_list_sessions(self, session_manager, binary_path):
|
|
"""Test listing active sessions."""
|
|
session = session_manager.create_session(binary_path)
|
|
sessions = session_manager.list_sessions()
|
|
|
|
assert len(sessions) == 1
|
|
assert sessions[0]["project_id"] == session.project_id
|
|
assert sessions[0]["arch"] == "AMD64"
|
|
assert sessions[0]["has_cfg"] is False
|
|
|
|
def test_list_sessions_multiple(self, session_manager, binary_path, i386_binary_path):
|
|
"""Test listing multiple sessions."""
|
|
session1 = session_manager.create_session(binary_path)
|
|
session2 = session_manager.create_session(i386_binary_path)
|
|
|
|
sessions = session_manager.list_sessions()
|
|
assert len(sessions) == 2
|
|
|
|
project_ids = {s["project_id"] for s in sessions}
|
|
assert session1.project_id in project_ids
|
|
assert session2.project_id in project_ids
|
|
|
|
def test_close_session(self, session_manager, binary_path):
|
|
"""Test closing a session."""
|
|
session = session_manager.create_session(binary_path)
|
|
project_id = session.project_id
|
|
|
|
result = session_manager.close_session(project_id)
|
|
assert result is True
|
|
|
|
# Verify session is gone
|
|
with pytest.raises(KeyError):
|
|
session_manager.get_session(project_id)
|
|
|
|
def test_close_session_not_found(self, session_manager):
|
|
"""Test closing nonexistent session returns False."""
|
|
result = session_manager.close_session("nonexistent")
|
|
assert result is False
|
|
|
|
def test_unique_project_ids(self, session_manager, binary_path):
|
|
"""Test that each session gets a unique project_id."""
|
|
sessions = [session_manager.create_session(binary_path) for _ in range(5)]
|
|
project_ids = [s.project_id for s in sessions]
|
|
|
|
assert len(project_ids) == len(set(project_ids))
|
|
|
|
|
|
class TestGlobalSessionManager:
|
|
"""Tests for the global session manager singleton."""
|
|
|
|
def test_get_session_manager_returns_same_instance(self):
|
|
"""Test that get_session_manager returns the same instance."""
|
|
manager1 = get_session_manager()
|
|
manager2 = get_session_manager()
|
|
|
|
assert manager1 is manager2
|
|
|
|
def test_global_manager_persistence(self, global_session_manager, binary_path):
|
|
"""Test that sessions persist in the global manager."""
|
|
session = global_session_manager.create_session(binary_path)
|
|
|
|
# Get manager again and verify session exists
|
|
manager = get_session_manager()
|
|
retrieved = manager.get_session(session.project_id)
|
|
|
|
assert retrieved.project_id == session.project_id
|
|
|
|
# Cleanup
|
|
manager.close_session(session.project_id)
|
|
|
|
|
|
class TestSessionLocking:
|
|
"""Tests for the per-session lock that serializes concurrent tool calls."""
|
|
|
|
def test_exclusive_is_reentrant(self, loaded_session):
|
|
"""Nested helpers must be able to re-acquire without deadlocking."""
|
|
with loaded_session.exclusive(), loaded_session.exclusive():
|
|
assert loaded_session.lock._is_owned() # pylint:disable=protected-access
|
|
|
|
def test_exclusive_serializes_threads(self, loaded_session):
|
|
"""Two threads must not be inside the critical section at the same time."""
|
|
overlaps = []
|
|
inside = []
|
|
barrier = threading.Barrier(2)
|
|
|
|
def worker():
|
|
barrier.wait()
|
|
for _ in range(50):
|
|
with loaded_session.exclusive():
|
|
inside.append(1)
|
|
if len(inside) > 1:
|
|
overlaps.append(1)
|
|
time.sleep(0)
|
|
inside.pop()
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(2)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert not overlaps
|
|
|
|
def test_tool_call_holds_the_session_lock(self, global_session_manager, binary_path):
|
|
"""Every tool runs under the lock, read-only ones included."""
|
|
session = global_session_manager.create_session(binary_path)
|
|
observed = []
|
|
|
|
@server._serialized # pylint:disable=protected-access
|
|
def probe(project_id: str) -> None:
|
|
observed.append(session.lock._is_owned()) # pylint:disable=protected-access
|
|
|
|
try:
|
|
probe(session.project_id)
|
|
finally:
|
|
global_session_manager.close_session(session.project_id)
|
|
assert observed == [True]
|
|
|
|
def test_tool_call_without_a_known_project_still_runs(self):
|
|
"""An unknown project must reach the tool so it can raise its own error."""
|
|
ran = []
|
|
|
|
@server._serialized # pylint:disable=protected-access
|
|
def probe(project_id: str) -> None:
|
|
ran.append(project_id)
|
|
|
|
probe("does-not-exist")
|
|
assert ran == ["does-not-exist"]
|