commit 717c5aaecc8e61cee5ca8f1bae80b3eade5f6985 Author: Damian Johnson atagar@torproject.org Date: Sun May 27 20:29:51 2012 -0700
Implementing Controller.get_info
Implementation and testing (both unit and a little integ) for GETINFO queries. There's still several todo notes to clean up, but the method itself is done. --- run_tests.py | 2 + stem/control.py | 129 +++++++++++++++++++++++++++++++ test/integ/connection/authentication.py | 2 +- test/integ/control/controller.py | 37 +++++++++ test/integ/socket/control_message.py | 4 - test/runner.py | 23 ++++-- test/unit/control/__init__.py | 6 ++ test/unit/control/getinfo.py | 119 ++++++++++++++++++++++++++++ 8 files changed, 308 insertions(+), 14 deletions(-)
diff --git a/run_tests.py b/run_tests.py index d123081..13fb027 100755 --- a/run_tests.py +++ b/run_tests.py @@ -17,6 +17,7 @@ import test.runner import test.check_whitespace import test.unit.connection.authentication import test.unit.connection.protocolinfo +import test.unit.control.getinfo import test.unit.socket.control_line import test.unit.socket.control_message import test.unit.descriptor.reader @@ -103,6 +104,7 @@ UNIT_TESTS = ( test.unit.socket.control_line.TestControlLine, test.unit.connection.authentication.TestAuthenticate, test.unit.connection.protocolinfo.TestProtocolInfoResponse, + test.unit.control.getinfo.TestGetInfoResponse, )
INTEG_TESTS = ( diff --git a/stem/control.py b/stem/control.py index e2d3d90..ccb57f4 100644 --- a/stem/control.py +++ b/stem/control.py @@ -36,6 +36,12 @@ import stem.util.log as log
State = stem.util.enum.Enum("INIT", "RESET", "CLOSED")
+# Constant to indicate an undefined argument default. Usually we'd use None for +# this, but users will commonly provide None as the argument so need something +# else very, very unique... + +UNDEFINED = "<Undefined>" * 10 + class BaseController: """ Controller for the tor process. This is a minimal base class for other @@ -429,4 +435,127 @@ class Controller(BaseController):
from_port = staticmethod(from_port) from_socket_file = staticmethod(from_socket_file) + + def get_info(self, param, default = UNDEFINED): + """ + Queries the control socket for the given GETINFO option. If provided a + default then that's returned if the GETINFO option is undefined or the + call fails for any reason (error response, control port closed, initiated, + etc). + + Arguments: + param (str, list) - GETINFO option or options to be queried + default (object) - response if the query fails + + Returns: + Response depends upon how we were called as follows... + - str with the response if our param was a str + - dict with the param => response mapping if our param was a list + - default if one was provided and our call failed + + Raises: + stem.socket.ControllerError if the call fails, and we weren't provided a + default response + """ + + # TODO: add caching? + # TODO: special geoip handling? + # TODO: add logging, including call runtime + + if isinstance(param, str): + is_multiple = False + param = [param] + else: + is_multiple = True + + try: + response = self.msg("GETINFO %s" % " ".join(param)) + + # TODO: replace with is_ok() check when we've merged it in + if response.content()[0][0] != "250": + raise stem.socket.ControllerError(str(response)) + + GetInfoResponse.convert(response) + + # error if we got back different parameters than we requested + requested_params = set(param) + reply_params = set(response.values.keys()) + + if requested_params != reply_params: + requested_label = ", ".join(requested_params) + reply_label = ", ".join(reply_params) + + raise stem.socket.ProtocolError("GETINFO reply doesn't match the parameters that we requested. Queried '%s' but got '%s'." % (requested_label, reply_label)) + + if is_multiple: + return response.values + else: + return response.values[param[0]] + except stem.socket.ControllerError, exc: + if default == UNDEFINED: raise exc + else: return default + +class GetInfoResponse(stem.socket.ControlMessage): + """ + Reply for a GETINFO query. + + Attributes: + values (dict) - mapping between the queried options and their values + """ + + def convert(control_message): + """ + Parses a ControlMessage, performing an in-place conversion of it into a + GetInfoResponse. + + Arguments: + control_message (stem.socket.ControlMessage) - + message to be parsed as a GETINFO reply + + Raises: + stem.socket.ProtocolError the message isn't a proper GETINFO response + TypeError if argument isn't a ControlMessage + """ + + if isinstance(control_message, stem.socket.ControlMessage): + control_message.__class__ = GetInfoResponse + control_message._parse_message() + return control_message + else: + raise TypeError("Only able to convert stem.socket.ControlMessage instances") + + convert = staticmethod(convert) + + def _parse_message(self): + # Example: + # 250-version=0.2.3.11-alpha-dev (git-ef0bc7f8f26a917c) + # 250+config-text= + # ControlPort 9051 + # DataDirectory /home/atagar/.tor + # ExitPolicy reject *:* + # Log notice stdout + # Nickname Unnamed + # ORPort 9050 + # . + # 250 OK + + self.values = {} + + for line in self: + if line == "OK": break + elif not "=" in line: + raise stem.socket.ProtocolError("GETINFO replies should only contain parameter=value mappings: %s" % line) + + key, value = line.split("=", 1) + + # if the value is a multiline value then it *must* be of the form + # '<key>=\n<value>' + + if "\n" in value: + if value.startswith("\n"): + value = value[1:] + else: + raise stem.socket.ProtocolError("GETINFO response contained a multiline value that didn't start with a newline: %s" % line) + + self.values[key] = value
diff --git a/test/integ/connection/authentication.py b/test/integ/connection/authentication.py index 9d912b3..5875a80 100644 --- a/test/integ/connection/authentication.py +++ b/test/integ/connection/authentication.py @@ -274,7 +274,7 @@ class TestAuthenticate(unittest.TestCase): """
auth_type = stem.connection.AuthMethod.COOKIE - auth_value = test.runner.get_runner().get_torrc_path() + auth_value = test.runner.get_runner().get_torrc_path(True)
if os.path.getsize(auth_value) == 32: # Weird coincidence? Fail so we can pick another file to check against. diff --git a/test/integ/control/controller.py b/test/integ/control/controller.py index 4bf8284..1b743e1 100644 --- a/test/integ/control/controller.py +++ b/test/integ/control/controller.py @@ -33,4 +33,41 @@ class TestController(unittest.TestCase): self.assertTrue(isinstance(controller, stem.control.Controller)) else: self.assertRaises(stem.socket.SocketError, stem.control.Controller.from_socket_file, test.runner.CONTROL_SOCKET_PATH) + + def test_getinfo(self): + """ + Exercises GETINFO with valid and invalid queries. + """ + + runner = test.runner.get_runner() + + with runner.get_tor_controller() as controller: + # successful single query + + torrc_path = runner.get_torrc_path() + self.assertEqual(torrc_path, controller.get_info("config-file")) + self.assertEqual(torrc_path, controller.get_info("config-file", "ho hum")) + + expected = {"config-file": torrc_path} + self.assertEqual(expected, controller.get_info(["config-file"])) + self.assertEqual(expected, controller.get_info(["config-file"], "ho hum")) + + # successful batch query, we don't know the values so just checking for + # the keys + + getinfo_params = set(["version", "config-file", "config/names"]) + self.assertEqual(getinfo_params, set(controller.get_info(["version", "config-file", "config/names"]).keys())) + + # non-existant option + + self.assertRaises(stem.socket.ControllerError, controller.get_info, "blarg") + self.assertEqual("ho hum", controller.get_info("blarg", "ho hum")) + + # empty input + + self.assertRaises(stem.socket.ControllerError, controller.get_info, "") + self.assertEqual("ho hum", controller.get_info("", "ho hum")) + + self.assertEqual({}, controller.get_info([])) + self.assertEqual({}, controller.get_info([], {}))
diff --git a/test/integ/socket/control_message.py b/test/integ/socket/control_message.py index 255e66d..72f9174 100644 --- a/test/integ/socket/control_message.py +++ b/test/integ/socket/control_message.py @@ -86,10 +86,6 @@ class TestControlMessage(unittest.TestCase):
runner = test.runner.get_runner() torrc_dst = runner.get_torrc_path() - chroot_path = runner.get_chroot() - - if chroot_path and torrc_dst.startswith(chroot_path): - torrc_dst = torrc_dst[len(chroot_path):]
with runner.get_tor_socket() as control_socket: control_socket.send("GETINFO config-file") diff --git a/test/runner.py b/test/runner.py index 286d4a8..ebbd55b 100644 --- a/test/runner.py +++ b/test/runner.py @@ -148,10 +148,7 @@ def exercise_controller(test_case, controller): """
runner = get_runner() - torrc_path, chroot_path = runner.get_torrc_path(), runner.get_chroot() - - if chroot_path and torrc_path.startswith(chroot_path): - torrc_path = torrc_path[len(chroot_path):] + torrc_path = runner.get_torrc_path()
if isinstance(controller, stem.socket.ControlSocket): controller.send("GETINFO config-file") @@ -384,10 +381,14 @@ class Runner: else: return self._get("_test_dir")
- def get_torrc_path(self): + def get_torrc_path(self, ignore_chroot = False): """ Provides the absolute path for where our testing torrc resides.
+ Arguments: + ignore_chroot (bool) - provides the real path, rather than the one that + tor expects if True + Returns: str with our torrc path
@@ -396,7 +397,12 @@ class Runner: """
test_dir = self._get("_test_dir") - return os.path.join(test_dir, "torrc") + torrc_path = os.path.join(test_dir, "torrc") + + if not ignore_chroot and self._chroot_path and torrc_path.startswith(self._chroot_path): + torrc_path = torrc_path[len(self._chroot_path):] + + return torrc_path
def get_torrc_contents(self): """ @@ -492,15 +498,14 @@ class Runner: authenticate (bool) - if True then the socket is authenticated
Returns: - stem.socket.BaseController connected with our testing instance + stem.socket.Controller connected with our testing instance
Raises: TorInaccessable if tor can't be connected to """
- # TODO: replace with our general controller when we have one control_socket = self.get_tor_socket(authenticate) - return stem.control.BaseController(control_socket) + return stem.control.Controller(control_socket)
def get_tor_version(self): """ diff --git a/test/unit/control/__init__.py b/test/unit/control/__init__.py new file mode 100644 index 0000000..448a597 --- /dev/null +++ b/test/unit/control/__init__.py @@ -0,0 +1,6 @@ +""" +Unit tests for stem.control. +""" + +__all__ = ["controller"] + diff --git a/test/unit/control/getinfo.py b/test/unit/control/getinfo.py new file mode 100644 index 0000000..3fc9fd3 --- /dev/null +++ b/test/unit/control/getinfo.py @@ -0,0 +1,119 @@ +""" +Unit tests for the stem.control.GetInfoResponse class. +""" + +import unittest + +import stem.connection +import test.mocking as mocking + +EMPTY_RESPONSE = "250 OK" + +SINGLE_RESPONSE = """\ +250-version=0.2.3.11-alpha-dev +250 OK""" + +BATCH_RESPONSE = """\ +250-version=0.2.3.11-alpha-dev +250-address=67.137.76.214 +250-fingerprint=5FDE0422045DF0E1879A3738D09099EB4A0C5BA0 +250 OK""" + +MULTILINE_RESPONSE = """\ +250-version=0.2.3.11-alpha-dev (git-ef0bc7f8f26a917c) +250+config-text= +ControlPort 9051 +DataDirectory /home/atagar/.tor +ExitPolicy reject *:* +Log notice stdout +Nickname Unnamed +ORPort 9050 +. +250 OK""" + +NON_KEY_VALUE_ENTRY = """\ +250-version=0.2.3.11-alpha-dev +250-address 67.137.76.214 +250 OK""" + +MISSING_MULTILINE_NEWLINE = """\ +250+config-text=ControlPort 9051 +DataDirectory /home/atagar/.tor +. +250 OK""" + +class TestGetInfoResponse(unittest.TestCase): + def test_empty_response(self): + """ + Parses a GETINFO reply without options (just calling "GETINFO"). + """ + + control_message = mocking.get_message(EMPTY_RESPONSE) + stem.control.GetInfoResponse.convert(control_message) + + # now this should be a GetInfoResponse (ControlMessage subclass) + self.assertTrue(isinstance(control_message, stem.socket.ControlMessage)) + self.assertTrue(isinstance(control_message, stem.control.GetInfoResponse)) + + self.assertEqual({}, control_message.values) + + def test_single_response(self): + """ + Parses a GETINFO reply response for a single parameter. + """ + + control_message = mocking.get_message(SINGLE_RESPONSE) + stem.control.GetInfoResponse.convert(control_message) + self.assertEqual({"version": "0.2.3.11-alpha-dev"}, control_message.values) + + def test_batch_response(self): + """ + Parses a GETINFO reply for muiltiple parameters. + """ + + control_message = mocking.get_message(BATCH_RESPONSE) + stem.control.GetInfoResponse.convert(control_message) + + expected = { + "version": "0.2.3.11-alpha-dev", + "address": "67.137.76.214", + "fingerprint": "5FDE0422045DF0E1879A3738D09099EB4A0C5BA0", + } + + self.assertEqual(expected, control_message.values) + + def test_multiline_response(self): + """ + Parses a GETINFO reply for multiple parameters including a multi-line + value. + """ + + control_message = mocking.get_message(MULTILINE_RESPONSE) + stem.control.GetInfoResponse.convert(control_message) + + expected = { + "version": "0.2.3.11-alpha-dev (git-ef0bc7f8f26a917c)", + "config-text": "\n".join(MULTILINE_RESPONSE.splitlines()[2:8]), + } + + self.assertEqual(expected, control_message.values) + + def test_invalid_non_mapping_content(self): + """ + Parses a malformed GETINFO reply containing a line that isn't a key=value + entry. + """ + + control_message = mocking.get_message(NON_KEY_VALUE_ENTRY) + self.assertRaises(stem.socket.ProtocolError, stem.control.GetInfoResponse.convert, control_message) + + def test_invalid_multiline_content(self): + """ + Parses a malformed GETINFO reply with a multi-line entry missing a newline + between its key and value. This is a proper controller message, but + malformed according to the GETINFO's spec. + """ + + control_message = mocking.get_message(MISSING_MULTILINE_NEWLINE) + self.assertRaises(stem.socket.ProtocolError, stem.control.GetInfoResponse.convert, control_message) +
tor-commits@lists.torproject.org