[tor-commits] [stem/master] Tor microdescriptor support

atagar at torproject.org atagar at torproject.org
Thu Feb 28 17:32:15 UTC 2013


commit 23d2332b2514fd9194e64cb1859614014087394b
Author: Damian Johnson <atagar at torproject.org>
Date:   Thu Feb 28 09:14:01 2013 -0800

    Tor microdescriptor support
    
    Adding parsing support for tor microdesriptors...
    
    https://trac.torproject.org/8253
    
    These have replaced server descriptors as the self-published descriptor content
    tor fetches by default. They're a bit clunckier to use compared to server
    descriptors, and lack much of the information controllers might be interested
    in, but the lighter weight of microdescriptors make them better for the overall
    network.
    
    Next up is to add support for these to our Controller. Unfortunately the tor
    control protocol only supports querying microdescriptors individually...
    
    https://trac.torproject.org/8323
---
 run_tests.py                                 |    4 +
 stem/descriptor/__init__.py                  |    7 +
 stem/descriptor/microdescriptor.py           |  226 ++++++++++++++++++++++++++
 stem/descriptor/router_status_entry.py       |    6 +-
 stem/descriptor/server_descriptor.py         |   14 +-
 test/integ/descriptor/__init__.py            |    1 +
 test/integ/descriptor/data/cached-microdescs |   25 +++
 test/integ/descriptor/microdescriptor.py     |  100 ++++++++++++
 test/mocking.py                              |   28 ++++
 test/settings.cfg                            |    2 +-
 test/unit/descriptor/__init__.py             |    1 +
 test/unit/descriptor/microdescriptor.py      |   94 +++++++++++
 test/unit/descriptor/router_status_entry.py  |    4 +-
 test/unit/descriptor/server_descriptor.py    |    8 +-
 14 files changed, 503 insertions(+), 17 deletions(-)

diff --git a/run_tests.py b/run_tests.py
index 4ec2dbd..93acb03 100755
--- a/run_tests.py
+++ b/run_tests.py
@@ -28,6 +28,7 @@ import test.unit.connection.authentication
 import test.unit.control.controller
 import test.unit.descriptor.export
 import test.unit.descriptor.extrainfo_descriptor
+import test.unit.descriptor.microdescriptor
 import test.unit.descriptor.networkstatus.bridge_document
 import test.unit.descriptor.networkstatus.directory_authority
 import test.unit.descriptor.networkstatus.document_v2
@@ -61,6 +62,7 @@ import test.integ.connection.connect
 import test.integ.control.base_controller
 import test.integ.control.controller
 import test.integ.descriptor.extrainfo_descriptor
+import test.integ.descriptor.microdescriptor
 import test.integ.descriptor.networkstatus
 import test.integ.descriptor.reader
 import test.integ.descriptor.server_descriptor
@@ -131,6 +133,7 @@ UNIT_TESTS = (
   test.unit.descriptor.reader.TestDescriptorReader,
   test.unit.descriptor.server_descriptor.TestServerDescriptor,
   test.unit.descriptor.extrainfo_descriptor.TestExtraInfoDescriptor,
+  test.unit.descriptor.microdescriptor.TestMicrodescriptor,
   test.unit.descriptor.router_status_entry.TestRouterStatusEntry,
   test.unit.descriptor.networkstatus.directory_authority.TestDirectoryAuthority,
   test.unit.descriptor.networkstatus.key_certificate.TestKeyCertificate,
@@ -161,6 +164,7 @@ INTEG_TESTS = (
   test.integ.descriptor.reader.TestDescriptorReader,
   test.integ.descriptor.server_descriptor.TestServerDescriptor,
   test.integ.descriptor.extrainfo_descriptor.TestExtraInfoDescriptor,
+  test.integ.descriptor.microdescriptor.TestMicrodescriptor,
   test.integ.descriptor.networkstatus.TestNetworkStatus,
   test.integ.version.TestVersion,
   test.integ.response.protocolinfo.TestProtocolInfo,
diff --git a/stem/descriptor/__init__.py b/stem/descriptor/__init__.py
index a734950..59d66a4 100644
--- a/stem/descriptor/__init__.py
+++ b/stem/descriptor/__init__.py
@@ -41,6 +41,7 @@ __all__ = [
   "reader",
   "extrainfo_descriptor",
   "server_descriptor",
+  "microdescriptor",
   "networkstatus",
   "router_status_entry",
   "parse_file",
@@ -100,6 +101,7 @@ def parse_file(descriptor_file, descriptor_type = None, validate = True, documen
   ========================================= =====
   server-descriptor 1.0                     :class:`~stem.descriptor.server_descriptor.RelayDescriptor`
   extra-info 1.0                            :class:`~stem.descriptor.extrainfo_descriptor.RelayExtraInfoDescriptor`
+  microdescriptor 1.0                       :class:`~stem.descriptor.microdescriptor.Microdescriptor`
   directory 1.0                             **unsupported**
   network-status-2 1.0                      :class:`~stem.descriptor.router_status_entry.RouterStatusEntryV2` (with a :class:`~stem.descriptor.networkstatus.NetworkStatusDocumentV2`)
   dir-key-certificate-3 1.0                 :class:`~stem.descriptor.networkstatus.KeyCertificate`
@@ -181,6 +183,8 @@ def parse_file(descriptor_file, descriptor_type = None, validate = True, documen
       file_parser = lambda f: stem.descriptor.server_descriptor._parse_file(f, validate = validate)
     elif filename == "cached-extrainfo":
       file_parser = lambda f: stem.descriptor.extrainfo_descriptor._parse_file(f, validate = validate)
+    elif filename == "cached-microdescs":
+      file_parser = lambda f: stem.descriptor.microdescriptor._parse_file(f, validate = validate)
     elif filename == "cached-consensus":
       file_parser = lambda f: stem.descriptor.networkstatus._parse_file(f, validate = validate, document_handler = document_handler)
     elif filename == "cached-microdesc-consensus":
@@ -216,6 +220,9 @@ def _parse_metrics_file(descriptor_type, major_version, minor_version, descripto
   elif descriptor_type == "extra-info" and major_version == 1:
     for desc in stem.descriptor.extrainfo_descriptor._parse_file(descriptor_file, is_bridge = False, validate = validate):
       yield desc
+  elif descriptor_type == "microdescriptor" and major_version == 1:
+    for desc in stem.descriptor.microdescriptor._parse_file(descriptor_file, validate = validate):
+      yield desc
   elif descriptor_type == "bridge-extra-info" and major_version == 1:
     # version 1.1 introduced a 'transport' field...
     # https://trac.torproject.org/6257
diff --git a/stem/descriptor/microdescriptor.py b/stem/descriptor/microdescriptor.py
new file mode 100644
index 0000000..e06d254
--- /dev/null
+++ b/stem/descriptor/microdescriptor.py
@@ -0,0 +1,226 @@
+# Copyright 2013, Damian Johnson
+# See LICENSE for licensing information
+
+"""
+Parsing for Tor microdescriptors, which contain a distilled version of a
+relay's server descriptor. As of Tor version 0.2.3.3-alpha Tor no longer
+downloads server descriptors by default, opting for microdescriptors instead.
+
+Unlike most descriptor documents these aren't available on the metrics site
+(since they don't contain any information that the server descriptors don't).
+
+**Module Overview:**
+
+::
+
+  Microdescriptor - Tor microdescriptor.
+"""
+
+import stem.descriptor
+import stem.descriptor.router_status_entry
+import stem.exit_policy
+
+REQUIRED_FIELDS = (
+  "onion-key",
+)
+
+SINGLE_FIELDS = (
+  "onion-key",
+  "ntor-onion-key",
+  "family",
+  "p",
+  "p6",
+)
+
+
+def _parse_file(descriptor_file, validate = True):
+  """
+  Iterates over the microdescriptors in a file.
+
+  :param file descriptor_file: file with descriptor content
+  :param bool validate: checks the validity of the descriptor's content if
+    **True**, skips these checks otherwise
+
+  :returns: iterator for Microdescriptor instances in the file
+
+  :raises:
+    * **ValueError** if the contents is malformed and validate is True
+    * **IOError** if the file can't be read
+  """
+
+  while True:
+    annotations = stem.descriptor._read_until_keywords("onion-key", descriptor_file)
+
+    # read until we reach an annotation or onion-key line
+    descriptor_lines = []
+
+    # read the onion-key line, done if we're at the end of the document
+
+    onion_key_line = descriptor_file.readline()
+
+    if onion_key_line:
+      descriptor_lines.append(onion_key_line)
+    else:
+      break
+
+    while True:
+      last_position = descriptor_file.tell()
+      line = descriptor_file.readline()
+
+      if not line:
+        break  # EOF
+      elif line.startswith("@") or line.startswith("onion-key"):
+        descriptor_file.seek(last_position)
+        break
+      else:
+        descriptor_lines.append(line)
+
+    if descriptor_lines:
+      # strip newlines from annotations
+      annotations = map(unicode.strip, annotations)
+
+      descriptor_text = "".join(descriptor_lines)
+
+      yield Microdescriptor(descriptor_text, validate, annotations)
+    else:
+      break  # done parsing descriptors
+
+
+class Microdescriptor(stem.descriptor.Descriptor):
+  """
+  Microdescriptor (`descriptor specification
+  <https://gitweb.torproject.org/torspec.git/blob/HEAD:/dir-spec.txt>`_)
+
+  :var str onion_key: **\*** key used to encrypt EXTEND cells
+  :var str ntor_onion_key: base64 key used to encrypt EXTEND in the ntor protocol
+  :var list or_addresses: **\*** alternative for our address/or_port attributes, each
+    entry is a tuple of the form (address (**str**), port (**int**), is_ipv6
+    (**bool**))
+  :var list family: **\*** nicknames or fingerprints of declared family
+  :var stem.exit_policy.MicroExitPolicy exit_policy: **\*** relay's exit policy
+  :var stem.exit_policy.MicroExitPolicy exit_policy_v6: **\*** exit policy for IPv6
+
+  **\*** attribute is required when we're parsed with validation
+  """
+
+  def __init__(self, raw_contents, validate = True, annotations = None):
+    super(Microdescriptor, self).__init__(raw_contents)
+
+    self.onion_key = None
+    self.ntor_onion_key = None
+    self.or_addresses = []
+    self.family = []
+    self.exit_policy = stem.exit_policy.MicroExitPolicy("reject 1-65535")
+    self.exit_policy_v6 = None
+
+    self._unrecognized_lines = []
+
+    self._annotation_lines = annotations if annotations else []
+    self._annotation_dict = None  # cached breakdown of key/value mappings
+
+    entries = stem.descriptor._get_descriptor_components(raw_contents, validate)
+    self._parse(entries, validate)
+
+    if validate:
+      self._check_constraints(entries)
+
+  def get_unrecognized_lines(self):
+    return list(self._unrecognized_lines)
+
+  def get_annotations(self):
+    """
+    Provides content that appeared prior to the descriptor. If this comes from
+    the cached-microdescs then this commonly contains content like...
+
+    ::
+
+      @last-listed 2013-02-24 00:18:30
+
+    :returns: **dict** with the key/value pairs in our annotations
+    """
+
+    if self._annotation_dict is None:
+      annotation_dict = {}
+
+      for line in self._annotation_lines:
+        if " " in line:
+          key, value = line.split(" ", 1)
+          annotation_dict[key] = value
+        else:
+          annotation_dict[line] = None
+
+      self._annotation_dict = annotation_dict
+
+    return self._annotation_dict
+
+  def get_annotation_lines(self):
+    """
+    Provides the lines of content that appeared prior to the descriptor. This
+    is the same as the
+    :func:`~stem.descriptor.microdescriptor.Microdescriptor.get_annotations`
+    results, but with the unparsed lines and ordering retained.
+
+    :returns: **list** with the lines of annotation that came before this descriptor
+    """
+
+    return self._annotation_lines
+
+  def _parse(self, entries, validate):
+    """
+    Parses a series of 'keyword => (value, pgp block)' mappings and applies
+    them as attributes.
+
+    :param dict entries: descriptor contents to be applied
+    :param bool validate: checks the validity of descriptor content if **True**
+
+    :raises: **ValueError** if an error occurs in validation
+    """
+
+    for keyword, values in entries.items():
+      # most just work with the first (and only) value
+      value, block_contents = values[0]
+
+      line = "%s %s" % (keyword, value)  # original line
+
+      if block_contents:
+        line += "\n%s" % block_contents
+
+      if keyword == "onion-key":
+        if validate and not block_contents:
+          raise ValueError("Onion key line must be followed by a public key: %s" % line)
+
+        self.onion_key = block_contents
+      elif keyword == "ntor-onion-key":
+        self.ntor_onion_key = value
+      elif keyword == "a":
+        for entry, _ in values:
+          stem.descriptor.router_status_entry._parse_a_line(self, entry, validate)
+      elif keyword == "family":
+        self.family = value.split(" ")
+      elif keyword == "p":
+        stem.descriptor.router_status_entry._parse_p_line(self, value, validate)
+      elif keyword == "p6":
+        self.exit_policy_v6 = stem.exit_policy.MicroExitPolicy(value)
+      else:
+        self._unrecognized_lines.append(line)
+
+  def _check_constraints(self, entries):
+    """
+    Does a basic check that the entries conform to this descriptor type's
+    constraints.
+
+    :param dict entries: keyword => (value, pgp key) entries
+
+    :raises: **ValueError** if an issue arises in validation
+    """
+
+    for keyword in REQUIRED_FIELDS:
+      if not keyword in entries:
+        raise ValueError("Microdescriptor must have a '%s' entry" % keyword)
+
+    for keyword in SINGLE_FIELDS:
+      if keyword in entries and len(entries[keyword]) > 1:
+        raise ValueError("The '%s' entry can only appear once in a microdescriptor" % keyword)
+
+    if "onion-key" != entries.keys()[0]:
+      raise ValueError("Microdescriptor must start with a 'onion-key' entry")
diff --git a/stem/descriptor/router_status_entry.py b/stem/descriptor/router_status_entry.py
index ca548b1..5a020b0 100644
--- a/stem/descriptor/router_status_entry.py
+++ b/stem/descriptor/router_status_entry.py
@@ -301,7 +301,7 @@ class RouterStatusEntryV3(RouterStatusEntry):
   Information about an individual router stored within a version 3 network
   status document.
 
-  :var list addresses_v6: **\*** relay's OR addresses, this is a tuple listing
+  :var list or_addresses: **\*** relay's OR addresses, this is a tuple listing
     of the form (address (**str**), port (**int**), is_ipv6 (**bool**))
   :var str digest: **\*** router's upper-case hex digest
 
@@ -321,7 +321,7 @@ class RouterStatusEntryV3(RouterStatusEntry):
   """
 
   def __init__(self, content, validate = True, document = None):
-    self.addresses_v6 = []
+    self.or_addresses = []
     self.digest = None
 
     self.bandwidth = None
@@ -544,7 +544,7 @@ def _parse_a_line(desc, value, validate):
       else:
         raise ValueError("%s 'a' line had an invalid port (%s): a %s" % (desc._name(), port, value))
 
-    desc.addresses_v6.append((address, int(port), is_ipv6))
+    desc.or_addresses.append((address, int(port), is_ipv6))
 
 
 def _parse_s_line(desc, value, validate):
diff --git a/stem/descriptor/server_descriptor.py b/stem/descriptor/server_descriptor.py
index 2db9a49..b1c1cef 100644
--- a/stem/descriptor/server_descriptor.py
+++ b/stem/descriptor/server_descriptor.py
@@ -158,7 +158,7 @@ class ServerDescriptor(stem.descriptor.Descriptor):
   :var int uptime: uptime when published in seconds
   :var str contact: contact information
   :var stem.exit_policy.ExitPolicy exit_policy: **\*** stated exit policy
-  :var stem.exit_policy.MicroExitPolicy exit_policy_v6: **\*** exit policy for IPv6
+  :var stem.exit_policy.MicroExitPolicy exit_policy_v6: exit policy for IPv6
   :var list family: **\*** nicknames or fingerprints of declared family
 
   :var int average_bandwidth: **\*** average rate it's willing to relay in bytes/s
@@ -172,9 +172,9 @@ class ServerDescriptor(stem.descriptor.Descriptor):
   :var bool extra_info_cache: **\*** flag if a mirror for extra-info documents
   :var str extra_info_digest: upper-case hex encoded digest of our extra-info document
   :var bool eventdns: flag for evdns backend (deprecated, always unset)
-  :var list address_alt: alternative for our address/or_port attributes, each
-    entry is a tuple of the form (address (**str**), port (**int**), is_ipv6
-    (**bool**))
+  :var list or_addresses: **\*** alternative for our address/or_port
+    attributes, each entry is a tuple of the form (address (**str**), port
+    (**int**), is_ipv6 (**bool**))
 
   Deprecated, moved to extra-info descriptor...
 
@@ -240,7 +240,7 @@ class ServerDescriptor(stem.descriptor.Descriptor):
     self.extra_info_digest = None
     self.hidden_service_dir = None
     self.eventdns = None
-    self.address_alt = []
+    self.or_addresses = []
 
     self.read_history_end = None
     self.read_history_interval = None
@@ -537,7 +537,7 @@ class ServerDescriptor(stem.descriptor.Descriptor):
               else:
                 raise ValueError("or-address line has malformed ports: %s" % line)
 
-            self.address_alt.append((address, int(port), is_ipv6))
+            self.or_addresses.append((address, int(port), is_ipv6))
       elif keyword in ("read-history", "write-history"):
         try:
           timestamp, interval, remainder = \
@@ -888,7 +888,7 @@ class BridgeDescriptor(ServerDescriptor):
       if self.contact and self.contact != "somebody":
         issues.append("Contact line should be scrubbed to be 'somebody', but instead had '%s'" % self.contact)
 
-      for address, _, is_ipv6 in self.address_alt:
+      for address, _, is_ipv6 in self.or_addresses:
         if not is_ipv6 and not address.startswith("10."):
           issues.append("or-address line's address should be scrubbed to be '10.x.x.x': %s" % address)
         elif is_ipv6 and not address.startswith("fd9f:2e19:3bcf::"):
diff --git a/test/integ/descriptor/__init__.py b/test/integ/descriptor/__init__.py
index 58f9f81..57083e9 100644
--- a/test/integ/descriptor/__init__.py
+++ b/test/integ/descriptor/__init__.py
@@ -5,6 +5,7 @@ Integration tests for stem.descriptor.* contents.
 __all__ = [
   "reader",
   "extrainfo_descriptor",
+  "microdescriptor",
   "server_descriptor",
   "get_resource",
   "open_desc",
diff --git a/test/integ/descriptor/data/cached-microdescs b/test/integ/descriptor/data/cached-microdescs
new file mode 100644
index 0000000..bd85ed6
--- /dev/null
+++ b/test/integ/descriptor/data/cached-microdescs
@@ -0,0 +1,25 @@
+ at last-listed 2013-02-24 00:18:36
+onion-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAMhPQtZPaxP3ukybV5LfofKQr20/ljpRk0e9IlGWWMSTkfVvBcHsa6IM
+H2KE6s4uuPHp7FqhakXAzJbODobnPHY8l1E4efyrqMQZXEQk2IMhgSNtG6YqUrVF
+CxdSKSSy0mmcBe2TOyQsahlGZ9Pudxfnrey7KcfqnArEOqNH09RpAgMBAAE=
+-----END RSA PUBLIC KEY-----
+ at last-listed 2013-02-24 00:18:37
+onion-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBALCOxZdpMI2WO496njSQ2M7b4IgAGATqpJmH3So7lXOa25sK6o7JipgP
+qQE83K/t/xsMIpxQ/hHkft3G78HkeXXFc9lVUzH0HmHwYEu0M+PMVULSkG36MfEl
+7WeSZzaG+Tlnh9OySAzVyTsv1ZJsTQFHH9V8wuM0GOMo9X8DFC+NAgMBAAE=
+-----END RSA PUBLIC KEY-----
+ntor-onion-key r5572HzD+PMPBbXlZwBhsm6YEbxnYgis8vhZ1jmdI2k=
+family $6141629FA0D15A6AEAEF3A1BEB76E64C767B3174
+ at last-listed 2013-02-24 00:18:36
+onion-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAOWFQHxO+5kGuhwPUX5jB7wJCrTbSU0fZwolNV1t9UaDdjGDvIjIhdit
+y2sMbyd9K8lbQO7x9rQjNst5ZicuaSOs854XQddSjm++vMdjYbOcVMqnKGSztvpd
+w/1LVWFfhcBnsGi4JMGbmP+KUZG9A8kI9deSyJhfi35jA7UepiHHAgMBAAE=
+-----END RSA PUBLIC KEY-----
+a [2001:6b0:7:125::242]:9001
+p accept 80,443
diff --git a/test/integ/descriptor/microdescriptor.py b/test/integ/descriptor/microdescriptor.py
new file mode 100644
index 0000000..329b5eb
--- /dev/null
+++ b/test/integ/descriptor/microdescriptor.py
@@ -0,0 +1,100 @@
+"""
+Integration tests for stem.descriptor.microdescriptor.
+"""
+
+from __future__ import with_statement
+
+import os
+import unittest
+
+import stem.descriptor
+import stem.exit_policy
+import test.runner
+
+from test.integ.descriptor import get_resource
+
+FIRST_ONION_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAMhPQtZPaxP3ukybV5LfofKQr20/ljpRk0e9IlGWWMSTkfVvBcHsa6IM
+H2KE6s4uuPHp7FqhakXAzJbODobnPHY8l1E4efyrqMQZXEQk2IMhgSNtG6YqUrVF
+CxdSKSSy0mmcBe2TOyQsahlGZ9Pudxfnrey7KcfqnArEOqNH09RpAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+SECOND_ONION_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBALCOxZdpMI2WO496njSQ2M7b4IgAGATqpJmH3So7lXOa25sK6o7JipgP
+qQE83K/t/xsMIpxQ/hHkft3G78HkeXXFc9lVUzH0HmHwYEu0M+PMVULSkG36MfEl
+7WeSZzaG+Tlnh9OySAzVyTsv1ZJsTQFHH9V8wuM0GOMo9X8DFC+NAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+THIRD_ONION_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAOWFQHxO+5kGuhwPUX5jB7wJCrTbSU0fZwolNV1t9UaDdjGDvIjIhdit
+y2sMbyd9K8lbQO7x9rQjNst5ZicuaSOs854XQddSjm++vMdjYbOcVMqnKGSztvpd
+w/1LVWFfhcBnsGi4JMGbmP+KUZG9A8kI9deSyJhfi35jA7UepiHHAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+
+class TestMicrodescriptor(unittest.TestCase):
+  def test_cached_microdescriptors(self):
+    """
+    Parses the cached microdescriptor file in our data directory, checking that
+    it doesn't raise any validation issues and looking for unrecognized
+    descriptor additions.
+    """
+
+    if test.runner.only_run_once(self, "test_cached_microdescriptors"):
+      return
+
+    descriptor_path = test.runner.get_runner().get_test_dir("cached-microdescs")
+
+    if not os.path.exists(descriptor_path):
+      test.runner.skip(self, "(no cached microdescriptors)")
+      return
+
+    with open(descriptor_path, 'rb') as descriptor_file:
+      for desc in stem.descriptor.parse_file(descriptor_file, "microdescriptor 1.0"):
+        unrecognized_lines = desc.get_unrecognized_lines()
+
+        if unrecognized_lines:
+          self.fail("Unrecognized microdescriptor content: %s" % unrecognized_lines)
+
+  def test_local_microdescriptors(self):
+    """
+    Checks a small microdescriptor file with known contents.
+    """
+
+    descriptor_path = get_resource("cached-microdescs")
+
+    with open(descriptor_path, 'rb') as descriptor_file:
+      descriptors = stem.descriptor.parse_file(descriptor_file, "microdescriptor 1.0")
+
+      router = next(descriptors)
+      self.assertEquals(FIRST_ONION_KEY, router.onion_key)
+      self.assertEquals(None, router.ntor_onion_key)
+      self.assertEquals([], router.or_addresses)
+      self.assertEquals([], router.family)
+      self.assertEquals(stem.exit_policy.MicroExitPolicy("reject 1-65535"), router.exit_policy)
+      self.assertEquals({"@last-listed": "2013-02-24 00:18:36"}, router.get_annotations())
+      self.assertEquals(["@last-listed 2013-02-24 00:18:36"], router.get_annotation_lines())
+
+      router = next(descriptors)
+      self.assertEquals(SECOND_ONION_KEY, router.onion_key)
+      self.assertEquals(u'r5572HzD+PMPBbXlZwBhsm6YEbxnYgis8vhZ1jmdI2k=', router.ntor_onion_key)
+      self.assertEquals([], router.or_addresses)
+      self.assertEquals(["$6141629FA0D15A6AEAEF3A1BEB76E64C767B3174"], router.family)
+      self.assertEquals(stem.exit_policy.MicroExitPolicy("reject 1-65535"), router.exit_policy)
+      self.assertEquals({"@last-listed": "2013-02-24 00:18:37"}, router.get_annotations())
+      self.assertEquals(["@last-listed 2013-02-24 00:18:37"], router.get_annotation_lines())
+
+      router = next(descriptors)
+      self.assertEquals(THIRD_ONION_KEY, router.onion_key)
+      self.assertEquals(None, router.ntor_onion_key)
+      self.assertEquals([(u"2001:6b0:7:125::242", 9001, True)], router.or_addresses)
+      self.assertEquals([], router.family)
+      self.assertEquals(stem.exit_policy.MicroExitPolicy("accept 80,443"), router.exit_policy)
+      self.assertEquals({"@last-listed": "2013-02-24 00:18:36"}, router.get_annotations())
+      self.assertEquals(["@last-listed 2013-02-24 00:18:36"], router.get_annotation_lines())
diff --git a/test/mocking.py b/test/mocking.py
index d912685..3ee8f08 100644
--- a/test/mocking.py
+++ b/test/mocking.py
@@ -35,6 +35,9 @@ calling :func:`test.mocking.revert_mocking`.
       get_relay_server_descriptor  - RelayDescriptor
       get_bridge_server_descriptor - BridgeDescriptor
 
+    stem.descriptor.microdescriptor
+      get_microdescriptor - Microdescriptor
+
     stem.descriptor.extrainfo_descriptor
       get_relay_extrainfo_descriptor  - RelayExtraInfoDescriptor
       get_bridge_extrainfo_descriptor - BridgeExtraInfoDescriptor
@@ -57,6 +60,7 @@ import inspect
 import itertools
 
 import stem.descriptor.extrainfo_descriptor
+import stem.descriptor.microdescriptor
 import stem.descriptor.networkstatus
 import stem.descriptor.router_status_entry
 import stem.descriptor.server_descriptor
@@ -126,6 +130,10 @@ BRIDGE_EXTRAINFO_FOOTER = (
   ("router-digest", "006FD96BA35E7785A6A3B8B75FE2E2435A13BDB4"),
 )
 
+MICRODESCRIPTOR = (
+  ("onion-key", "\n-----BEGIN RSA PUBLIC KEY-----%s-----END RSA PUBLIC KEY-----" % CRYPTO_BLOB),
+)
+
 ROUTER_STATUS_ENTRY_V2_HEADER = (
   ("r", "caerSidi p1aag7VwarGxqctS7/fS0y5FU+s oQZFLYe9e4A7bOkWKR7TaNxb0JE 2012-08-06 11:19:31 71.35.150.29 9001 0"),
 )
@@ -709,6 +717,26 @@ def get_bridge_extrainfo_descriptor(attr = None, exclude = (), content = False):
     return stem.descriptor.extrainfo_descriptor.BridgeExtraInfoDescriptor(desc_content, validate = True)
 
 
+def get_microdescriptor(attr = None, exclude = (), content = False):
+  """
+  Provides the descriptor content for...
+  stem.descriptor.microdescriptor.Microdescriptor
+
+  :param dict attr: keyword/value mappings to be included in the descriptor
+  :param list exclude: mandatory keywords to exclude from the descriptor
+  :param bool content: provides the str content of the descriptor rather than the class if True
+
+  :returns: Microdescriptor for the requested descriptor content
+  """
+
+  desc_content = _get_descriptor_content(attr, exclude, MICRODESCRIPTOR)
+
+  if content:
+    return desc_content
+  else:
+    return stem.descriptor.microdescriptor.Microdescriptor(desc_content, validate = True)
+
+
 def get_router_status_entry_v2(attr = None, exclude = (), content = False):
   """
   Provides the descriptor content for...
diff --git a/test/settings.cfg b/test/settings.cfg
index 558b43b..c184eec 100644
--- a/test/settings.cfg
+++ b/test/settings.cfg
@@ -157,7 +157,7 @@ target.torrc RUN_PTRACE   => PORT, PTRACE
 pyflakes.ignore stem/prereq.py => 'RSA' imported but unused
 pyflakes.ignore stem/prereq.py => 'asn1' imported but unused
 pyflakes.ignore stem/prereq.py => 'long_to_bytes' imported but unused
-pyflakes.ignore stem/descriptor/__init__.py => redefinition of unused 'OrderedDict' from line 59
+pyflakes.ignore stem/descriptor/__init__.py => redefinition of unused 'OrderedDict' from line 60
 pyflakes.ignore stem/util/str_tools.py => redefinition of function '_to_bytes' from line 56
 pyflakes.ignore stem/util/str_tools.py => redefinition of function '_to_unicode' from line 62
 pyflakes.ignore test/mocking.py => undefined name 'builtins'
diff --git a/test/unit/descriptor/__init__.py b/test/unit/descriptor/__init__.py
index aceb1ea..c67b603 100644
--- a/test/unit/descriptor/__init__.py
+++ b/test/unit/descriptor/__init__.py
@@ -5,6 +5,7 @@ Unit tests for stem.descriptor.
 __all__ = [
   "export",
   "extrainfo_descriptor",
+  "microdescriptor",
   "networkstatus",
   "reader",
   "router_status_entry",
diff --git a/test/unit/descriptor/microdescriptor.py b/test/unit/descriptor/microdescriptor.py
new file mode 100644
index 0000000..2a2dd35
--- /dev/null
+++ b/test/unit/descriptor/microdescriptor.py
@@ -0,0 +1,94 @@
+"""
+Unit tests for stem.descriptor.microdescriptor.
+"""
+
+import unittest
+
+import stem.exit_policy
+
+from stem.descriptor.microdescriptor import Microdescriptor
+from test.mocking import get_microdescriptor, \
+                         CRYPTO_BLOB
+
+
+class TestMicrodescriptor(unittest.TestCase):
+  def test_minimal_microdescriptor(self):
+    """
+    Basic sanity check that we can parse a microdescriptor with minimal
+    attributes.
+    """
+
+    desc = get_microdescriptor()
+
+    self.assertTrue(CRYPTO_BLOB in desc.onion_key)
+    self.assertEquals(None, desc.ntor_onion_key)
+    self.assertEquals([], desc.or_addresses)
+    self.assertEquals([], desc.family)
+    self.assertEquals(stem.exit_policy.MicroExitPolicy("reject 1-65535"), desc.exit_policy)
+    self.assertEquals(None, desc.exit_policy_v6)
+    self.assertEquals([], desc.get_unrecognized_lines())
+
+  def test_unrecognized_line(self):
+    """
+    Includes unrecognized content in the descriptor.
+    """
+
+    desc = get_microdescriptor({"pepperjack": "is oh so tasty!"})
+    self.assertEquals(["pepperjack is oh so tasty!"], desc.get_unrecognized_lines())
+
+  def test_proceeding_line(self):
+    """
+    Includes a line prior to the 'onion-key' entry.
+    """
+
+    desc_text = "family Amunet1\n" + get_microdescriptor(content = True)
+    self.assertRaises(ValueError, Microdescriptor, desc_text)
+
+    desc = Microdescriptor(desc_text, validate = False)
+    self.assertEquals(["Amunet1"], desc.family)
+
+  def test_a_line(self):
+    """
+    Sanity test with both an IPv4 and IPv6 address.
+    """
+
+    desc_text = get_microdescriptor(content = True)
+    desc_text += "\na 10.45.227.253:9001"
+    desc_text += "\na [fd9f:2e19:3bcf::02:9970]:9001"
+
+    expected = [
+      ("10.45.227.253", 9001, False),
+      ("fd9f:2e19:3bcf::02:9970", 9001, True),
+    ]
+
+    desc = Microdescriptor(desc_text)
+    self.assertEquals(expected, desc.or_addresses)
+
+  def test_family(self):
+    """
+    Check the family line.
+    """
+
+    desc = get_microdescriptor({"family": "Amunet1 Amunet2 Amunet3"})
+    self.assertEquals(["Amunet1", "Amunet2", "Amunet3"], desc.family)
+
+    # try multiple family lines
+
+    desc_text = get_microdescriptor(content = True)
+    desc_text += "\nfamily Amunet1"
+    desc_text += "\nfamily Amunet2"
+
+    self.assertRaises(ValueError, Microdescriptor, desc_text)
+
+    # family entries will overwrite each other
+    desc = Microdescriptor(desc_text, validate = False)
+    self.assertEquals(1, len(desc.family))
+
+  def test_exit_policy(self):
+    """
+    Basic check for 'p' lines. The router status entries contain an identical
+    field so we're not investing much effort here.
+    """
+
+    desc = get_microdescriptor({"p": "accept 80,110,143,443"})
+    self.assertEquals(stem.exit_policy.MicroExitPolicy("accept 80,110,143,443"), desc.exit_policy)
diff --git a/test/unit/descriptor/router_status_entry.py b/test/unit/descriptor/router_status_entry.py
index d01666b..23baaeb 100644
--- a/test/unit/descriptor/router_status_entry.py
+++ b/test/unit/descriptor/router_status_entry.py
@@ -321,7 +321,7 @@ class TestRouterStatusEntry(unittest.TestCase):
 
     for a_line, expected in test_values.items():
       entry = get_router_status_entry_v3({'a': a_line})
-      self.assertEquals(expected, entry.addresses_v6)
+      self.assertEquals(expected, entry.or_addresses)
 
     # includes multiple 'a' lines
 
@@ -336,7 +336,7 @@ class TestRouterStatusEntry(unittest.TestCase):
     ]
 
     entry = RouterStatusEntryV3(content)
-    self.assertEquals(expected, entry.addresses_v6)
+    self.assertEquals(expected, entry.or_addresses)
 
     # tries some invalid inputs
 
diff --git a/test/unit/descriptor/server_descriptor.py b/test/unit/descriptor/server_descriptor.py
index 66ec460..07fb50c 100644
--- a/test/unit/descriptor/server_descriptor.py
+++ b/test/unit/descriptor/server_descriptor.py
@@ -365,7 +365,7 @@ class TestServerDescriptor(unittest.TestCase):
     """
 
     desc = get_bridge_server_descriptor({"or-address": "10.45.227.253:9001"})
-    self.assertEquals([("10.45.227.253", 9001, False)], desc.address_alt)
+    self.assertEquals([("10.45.227.253", 9001, False)], desc.or_addresses)
 
   def test_or_address_v6(self):
     """
@@ -373,7 +373,7 @@ class TestServerDescriptor(unittest.TestCase):
     """
 
     desc = get_bridge_server_descriptor({"or-address": "[fd9f:2e19:3bcf::02:9970]:9001"})
-    self.assertEquals([("fd9f:2e19:3bcf::02:9970", 9001, True)], desc.address_alt)
+    self.assertEquals([("fd9f:2e19:3bcf::02:9970", 9001, True)], desc.or_addresses)
 
   def test_or_address_multiple(self):
     """
@@ -384,7 +384,7 @@ class TestServerDescriptor(unittest.TestCase):
                           "or-address 10.45.227.253:9001,9005,80",
                           "or-address [fd9f:2e19:3bcf::02:9970]:443"))
 
-    expected_address_alt = [
+    expected_or_addresses = [
       ("10.45.227.253", 9001, False),
       ("10.45.227.253", 9005, False),
       ("10.45.227.253", 80, False),
@@ -392,7 +392,7 @@ class TestServerDescriptor(unittest.TestCase):
     ]
 
     desc = BridgeDescriptor(desc_text)
-    self.assertEquals(expected_address_alt, desc.address_alt)
+    self.assertEquals(expected_or_addresses, desc.or_addresses)
 
   def _expect_invalid_attr(self, desc_text, attr = None, expected_value = None):
     """



More information about the tor-commits mailing list