[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[tor-commits] [metrics-lib/master] Move all Java sources to src/main/java/.



commit 600a020877bb82ee1fd5852a5694d268411e2ed4
Author: Karsten Loesing <karsten.loesing@xxxxxxx>
Date:   Tue Jul 5 10:28:06 2016 +0200

    Move all Java sources to src/main/java/.
---
 build.xml                                          |   10 +-
 .../torproject/descriptor/BandwidthHistory.java    |   52 +
 .../descriptor/BridgeExtraInfoDescriptor.java      |   25 +
 .../torproject/descriptor/BridgeNetworkStatus.java |  128 ++
 .../descriptor/BridgePoolAssignment.java           |   47 +
 .../descriptor/BridgeServerDescriptor.java         |   24 +
 .../java/org/torproject/descriptor/Descriptor.java |   39 +
 .../torproject/descriptor/DescriptorCollector.java |   62 +
 .../descriptor/DescriptorDownloader.java           |  198 +++
 .../org/torproject/descriptor/DescriptorFile.java  |   77 +
 .../descriptor/DescriptorParseException.java       |   20 +
 .../torproject/descriptor/DescriptorParser.java    |   47 +
 .../torproject/descriptor/DescriptorReader.java    |  143 ++
 .../torproject/descriptor/DescriptorRequest.java   |  100 ++
 .../descriptor/DescriptorSourceFactory.java        |  187 +++
 .../org/torproject/descriptor/DirSourceEntry.java  |   96 ++
 .../descriptor/DirectoryKeyCertificate.java        |  109 ++
 .../torproject/descriptor/DirectorySignature.java  |   52 +
 .../java/org/torproject/descriptor/ExitList.java   |   92 ++
 .../org/torproject/descriptor/ExitListEntry.java   |   55 +
 .../torproject/descriptor/ExtraInfoDescriptor.java |  646 ++++++++
 .../ImplementationNotAccessibleException.java      |   22 +
 .../org/torproject/descriptor/Microdescriptor.java |  135 ++
 .../torproject/descriptor/NetworkStatusEntry.java  |  177 ++
 .../org/torproject/descriptor/RelayDirectory.java  |  104 ++
 .../descriptor/RelayExtraInfoDescriptor.java       |   21 +
 .../torproject/descriptor/RelayNetworkStatus.java  |  176 ++
 .../descriptor/RelayNetworkStatusConsensus.java    |  223 +++
 .../descriptor/RelayNetworkStatusVote.java         |  408 +++++
 .../descriptor/RelayServerDescriptor.java          |   20 +
 .../torproject/descriptor/RouterStatusEntry.java   |   51 +
 .../torproject/descriptor/ServerDescriptor.java    |  435 +++++
 .../org/torproject/descriptor/TorperfResult.java   |  215 +++
 .../descriptor/impl/BandwidthHistoryImpl.java      |  100 ++
 .../descriptor/impl/BlockingIteratorImpl.java      |   98 ++
 .../impl/BridgeExtraInfoDescriptorImpl.java        |   37 +
 .../descriptor/impl/BridgeNetworkStatusImpl.java   |  230 +++
 .../descriptor/impl/BridgePoolAssignmentImpl.java  |   99 ++
 .../impl/BridgeServerDescriptorImpl.java           |   37 +
 .../descriptor/impl/DescriptorCollectorImpl.java   |  249 +++
 .../descriptor/impl/DescriptorDownloaderImpl.java  |  283 ++++
 .../descriptor/impl/DescriptorFileImpl.java        |   78 +
 .../torproject/descriptor/impl/DescriptorImpl.java |  337 ++++
 .../descriptor/impl/DescriptorParseException.java  |   15 +
 .../descriptor/impl/DescriptorParserImpl.java      |   28 +
 .../descriptor/impl/DescriptorReaderImpl.java      |  364 ++++
 .../descriptor/impl/DescriptorRequestImpl.java     |  114 ++
 .../descriptor/impl/DirSourceEntryImpl.java        |  218 +++
 .../descriptor/impl/DirectoryDownloader.java       |  104 ++
 .../impl/DirectoryKeyCertificateImpl.java          |  308 ++++
 .../descriptor/impl/DirectorySignatureImpl.java    |  115 ++
 .../descriptor/impl/DownloadCoordinator.java       |   10 +
 .../descriptor/impl/DownloadCoordinatorImpl.java   |  298 ++++
 .../descriptor/impl/ExitListEntryImpl.java         |  216 +++
 .../torproject/descriptor/impl/ExitListImpl.java   |  142 ++
 .../descriptor/impl/ExtraInfoDescriptorImpl.java   | 1284 +++++++++++++++
 .../descriptor/impl/MicrodescriptorImpl.java       |  328 ++++
 .../descriptor/impl/NetworkStatusEntryImpl.java    |  382 +++++
 .../descriptor/impl/NetworkStatusImpl.java         |  270 +++
 .../torproject/descriptor/impl/ParseHelper.java    |  567 +++++++
 .../descriptor/impl/RelayDirectoryImpl.java        |  547 ++++++
 .../impl/RelayExtraInfoDescriptorImpl.java         |   37 +
 .../impl/RelayNetworkStatusConsensusImpl.java      |  414 +++++
 .../descriptor/impl/RelayNetworkStatusImpl.java    |  384 +++++
 .../impl/RelayNetworkStatusVoteImpl.java           |  761 +++++++++
 .../descriptor/impl/RelayServerDescriptorImpl.java |   37 +
 .../descriptor/impl/RouterStatusEntryImpl.java     |   41 +
 .../descriptor/impl/ServerDescriptorImpl.java      |  985 +++++++++++
 .../descriptor/impl/TorperfResultImpl.java         |  546 ++++++
 .../org/torproject/descriptor/package-info.java    |   80 +
 .../torproject/descriptor/BandwidthHistory.java    |   52 -
 .../descriptor/BridgeExtraInfoDescriptor.java      |   25 -
 .../torproject/descriptor/BridgeNetworkStatus.java |  128 --
 .../descriptor/BridgePoolAssignment.java           |   47 -
 .../descriptor/BridgeServerDescriptor.java         |   24 -
 src/org/torproject/descriptor/Descriptor.java      |   39 -
 .../torproject/descriptor/DescriptorCollector.java |   62 -
 .../descriptor/DescriptorDownloader.java           |  198 ---
 src/org/torproject/descriptor/DescriptorFile.java  |   77 -
 .../descriptor/DescriptorParseException.java       |   20 -
 .../torproject/descriptor/DescriptorParser.java    |   47 -
 .../torproject/descriptor/DescriptorReader.java    |  143 --
 .../torproject/descriptor/DescriptorRequest.java   |  100 --
 .../descriptor/DescriptorSourceFactory.java        |  187 ---
 src/org/torproject/descriptor/DirSourceEntry.java  |   96 --
 .../descriptor/DirectoryKeyCertificate.java        |  109 --
 .../torproject/descriptor/DirectorySignature.java  |   52 -
 src/org/torproject/descriptor/ExitList.java        |   92 --
 src/org/torproject/descriptor/ExitListEntry.java   |   55 -
 .../torproject/descriptor/ExtraInfoDescriptor.java |  646 --------
 .../ImplementationNotAccessibleException.java      |   22 -
 src/org/torproject/descriptor/Microdescriptor.java |  135 --
 .../torproject/descriptor/NetworkStatusEntry.java  |  177 --
 src/org/torproject/descriptor/RelayDirectory.java  |  104 --
 .../descriptor/RelayExtraInfoDescriptor.java       |   21 -
 .../torproject/descriptor/RelayNetworkStatus.java  |  176 --
 .../descriptor/RelayNetworkStatusConsensus.java    |  223 ---
 .../descriptor/RelayNetworkStatusVote.java         |  408 -----
 .../descriptor/RelayServerDescriptor.java          |   20 -
 .../torproject/descriptor/RouterStatusEntry.java   |   51 -
 .../torproject/descriptor/ServerDescriptor.java    |  435 -----
 src/org/torproject/descriptor/TorperfResult.java   |  215 ---
 .../descriptor/impl/BandwidthHistoryImpl.java      |  100 --
 .../descriptor/impl/BlockingIteratorImpl.java      |   98 --
 .../impl/BridgeExtraInfoDescriptorImpl.java        |   37 -
 .../descriptor/impl/BridgeNetworkStatusImpl.java   |  230 ---
 .../descriptor/impl/BridgePoolAssignmentImpl.java  |   99 --
 .../impl/BridgeServerDescriptorImpl.java           |   37 -
 .../descriptor/impl/DescriptorCollectorImpl.java   |  249 ---
 .../descriptor/impl/DescriptorDownloaderImpl.java  |  283 ----
 .../descriptor/impl/DescriptorFileImpl.java        |   78 -
 .../torproject/descriptor/impl/DescriptorImpl.java |  337 ----
 .../descriptor/impl/DescriptorParseException.java  |   15 -
 .../descriptor/impl/DescriptorParserImpl.java      |   28 -
 .../descriptor/impl/DescriptorReaderImpl.java      |  364 ----
 .../descriptor/impl/DescriptorRequestImpl.java     |  114 --
 .../descriptor/impl/DirSourceEntryImpl.java        |  218 ---
 .../descriptor/impl/DirectoryDownloader.java       |  104 --
 .../impl/DirectoryKeyCertificateImpl.java          |  308 ----
 .../descriptor/impl/DirectorySignatureImpl.java    |  115 --
 .../descriptor/impl/DownloadCoordinator.java       |   10 -
 .../descriptor/impl/DownloadCoordinatorImpl.java   |  298 ----
 .../descriptor/impl/ExitListEntryImpl.java         |  216 ---
 .../torproject/descriptor/impl/ExitListImpl.java   |  142 --
 .../descriptor/impl/ExtraInfoDescriptorImpl.java   | 1284 ---------------
 .../descriptor/impl/MicrodescriptorImpl.java       |  328 ----
 .../descriptor/impl/NetworkStatusEntryImpl.java    |  382 -----
 .../descriptor/impl/NetworkStatusImpl.java         |  270 ---
 .../torproject/descriptor/impl/ParseHelper.java    |  567 -------
 .../descriptor/impl/RelayDirectoryImpl.java        |  547 ------
 .../impl/RelayExtraInfoDescriptorImpl.java         |   37 -
 .../impl/RelayNetworkStatusConsensusImpl.java      |  414 -----
 .../descriptor/impl/RelayNetworkStatusImpl.java    |  384 -----
 .../impl/RelayNetworkStatusVoteImpl.java           |  761 ---------
 .../descriptor/impl/RelayServerDescriptorImpl.java |   37 -
 .../descriptor/impl/RouterStatusEntryImpl.java     |   41 -
 .../descriptor/impl/ServerDescriptorImpl.java      |  985 -----------
 .../descriptor/impl/TorperfResultImpl.java         |  546 ------
 src/org/torproject/descriptor/package-info.java    |   80 -
 .../descriptor/benchmark/MeasurePerformance.java   |  278 ++++
 .../descriptor/impl/BridgeNetworkStatusTest.java   |  151 ++
 .../descriptor/impl/ConsensusBuilder.java          |  321 ++++
 .../impl/DescriptorCollectorImplTest.java          |  134 ++
 .../descriptor/impl/ExitListImplTest.java          |  131 ++
 .../impl/ExtraInfoDescriptorImplTest.java          | 1737 ++++++++++++++++++++
 .../descriptor/impl/MicrodescriptorImplTest.java   |   82 +
 .../impl/RelayNetworkStatusConsensusImplTest.java  | 1272 ++++++++++++++
 .../impl/RelayNetworkStatusVoteImplTest.java       | 1373 ++++++++++++++++
 .../descriptor/impl/ServerDescriptorImplTest.java  | 1605 ++++++++++++++++++
 .../descriptor/impl/TorperfResultImplTest.java     |   97 ++
 .../descriptor/benchmark/MeasurePerformance.java   |  278 ----
 .../descriptor/impl/BridgeNetworkStatusTest.java   |  151 --
 .../descriptor/impl/ConsensusBuilder.java          |  321 ----
 .../impl/DescriptorCollectorImplTest.java          |  134 --
 .../descriptor/impl/ExitListImplTest.java          |  131 --
 .../impl/ExtraInfoDescriptorImplTest.java          | 1737 --------------------
 .../descriptor/impl/MicrodescriptorImplTest.java   |   82 -
 .../impl/RelayNetworkStatusConsensusImplTest.java  | 1272 --------------
 .../impl/RelayNetworkStatusVoteImplTest.java       | 1373 ----------------
 .../descriptor/impl/ServerDescriptorImplTest.java  | 1605 ------------------
 .../descriptor/impl/TorperfResultImplTest.java     |   97 --
 161 files changed, 21515 insertions(+), 21515 deletions(-)

diff --git a/build.xml b/build.xml
index 6bb773b..0d6cf7d 100644
--- a/build.xml
+++ b/build.xml
@@ -1,10 +1,10 @@
 <project default="jar" name="descriptor" basedir=".">
   <property name="release.version" value="1.2.0-dev" />
-  <property name="sources" value="src"/>
+  <property name="sources" value="src/main/java"/>
   <property name="resources" value="resources"/>
   <property name="classes" value="classes"/>
   <property name="docs" value="javadoc"/>
-  <property name="tests" value="test"/>
+  <property name="tests" value="src/test/java"/>
   <property name="libs" value="lib"/>
   <property name="jarfile" value="descriptor-${release.version}.jar" />
   <property name="jarsourcesfile"
@@ -152,11 +152,11 @@
         <include name="*.md" />
       </tarfileset>
       <tarfileset dir="${sources}"
-                  prefix="descriptor-${release.version}/src" />
+                  prefix="descriptor-${release.version}/${sources}" />
       <tarfileset dir="${tests}"
-                  prefix="descriptor-${release.version}/test" />
+                  prefix="descriptor-${release.version}/${tests}" />
       <tarfileset dir="${libs}"
-                  prefix="descriptor-${release.version}/lib" />
+                  prefix="descriptor-${release.version}/${libs}" />
     </tar>
   </target>
 </project>
diff --git a/src/main/java/org/torproject/descriptor/BandwidthHistory.java b/src/main/java/org/torproject/descriptor/BandwidthHistory.java
new file mode 100644
index 0000000..0be1a53
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/BandwidthHistory.java
@@ -0,0 +1,52 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.SortedMap;
+
+/**
+ * Contains the bandwidth history of a relay or bridge.
+ *
+ * <p>A bandwidth history is not a descriptor type of its own but usually
+ * part of extra-info descriptors ({@link ExtraInfoDescriptor}) or server
+ * descriptors ({@link ServerDescriptor}).</p>
+ *
+ * @since 1.0.0
+ */
+public interface BandwidthHistory {
+
+  /**
+   * Return the original bandwidth history line as contained in the
+   * descriptor, possibly prefixed with {@code "opt "}.
+   *
+   * @since 1.0.0
+   */
+  public String getLine();
+
+  /**
+   * Return the time in milliseconds since the epoch when the most recent
+   * interval ends.
+   *
+   * @since 1.0.0
+   */
+  public long getHistoryEndMillis();
+
+  /**
+   * Return the interval length in seconds.
+   *
+   * @since 1.0.0
+   */
+  public long getIntervalLength();
+
+  /**
+   * Return the (possibly empty) bandwidth history with map keys being
+   * times in milliseconds since the epoch when intervals end and map
+   * values being number of bytes used in the interval, ordered from
+   * oldest to newest interval.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<Long, Long> getBandwidthValues();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/BridgeExtraInfoDescriptor.java b/src/main/java/org/torproject/descriptor/BridgeExtraInfoDescriptor.java
new file mode 100644
index 0000000..a3c168d
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/BridgeExtraInfoDescriptor.java
@@ -0,0 +1,25 @@
+/* Copyright 2015--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains a sanitized bridge extra-info descriptor.
+ *
+ * <p>Sanitized bridge extra-info descriptors share many contents with
+ * relay extra-info descriptors ({@link RelayExtraInfoDescriptor}), which
+ * is why they share a common
+ * superinterface ({@link ExtraInfoDescriptor}).  The main purpose of
+ * having two subinterfaces is being able to distinguish descriptor types
+ * more easily.</p>
+ *
+ * <p>Details about sanitizing bridge extra-info descriptors can be found
+ * <a href="https://collector.torproject.org/#type-bridge-extra-info";>here</a>.
+ * </p>
+ *
+ * @since 1.1.0
+ */
+public interface BridgeExtraInfoDescriptor extends ExtraInfoDescriptor {
+
+}
+
diff --git a/src/main/java/org/torproject/descriptor/BridgeNetworkStatus.java b/src/main/java/org/torproject/descriptor/BridgeNetworkStatus.java
new file mode 100644
index 0000000..c7458fd
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/BridgeNetworkStatus.java
@@ -0,0 +1,128 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.SortedMap;
+
+/**
+ * Contains a sanitized bridge network status document.
+ *
+ * <p>The bridge directory authority periodically publishes a network
+ * status document with one entry per known bridge in the network
+ * ({@link NetworkStatusEntry}) containing: a hash of its identity key, a
+ * hash of its most recent server descriptor, and a summary of what the
+ * bridge authority believed about its status.</p>
+ *
+ * <p>The main purpose of this document is to get an authoritative list of
+ * running bridges to the bridge distribution service BridgeDB.</p>
+ *
+ * <p>Details about sanitizing bridge network statuses can be found
+ * <a href="https://collector.torproject.org/#type-bridge-network-status";>here</a>.
+ * </p>
+ *
+ * @since 1.0.0
+ */
+public interface BridgeNetworkStatus extends Descriptor {
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the minimum uptime in seconds that this authority requires
+   * for assigning the Stable flag, or -1 if the authority doesn't report
+   * this value.
+   *
+   * @since 1.1.0
+   */
+  public long getStableUptime();
+
+  /**
+   * Return the minimum MTBF (mean time between failure) that this
+   * authority requires for assigning the Stable flag, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.1.0
+   */
+  public long getStableMtbf();
+
+  /**
+   * Return the minimum bandwidth that this authority requires for
+   * assigning the Fast flag, or -1 if the authority doesn't report this
+   * value.
+   *
+   * @since 1.1.0
+   */
+  public long getFastBandwidth();
+
+  /**
+   * Return the minimum WFU (weighted fractional uptime) in percent that
+   * this authority requires for assigning the Guard flag, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.1.0
+   */
+  public double getGuardWfu();
+
+  /**
+   * Return the minimum weighted time in seconds that this authority
+   * needs to know about a relay before assigning the Guard flag, or -1 if
+   * the authority doesn't report this information.
+   *
+   * @since 1.1.0
+   */
+  public long getGuardTk();
+
+  /**
+   * Return the minimum bandwidth that this authority requires for
+   * assigning the Guard flag if exits can be guards, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.1.0
+   */
+  public long getGuardBandwidthIncludingExits();
+
+  /**
+   * Return the minimum bandwidth that this authority requires for
+   * assigning the Guard flag if exits can not be guards, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.1.0
+   */
+  public long getGuardBandwidthExcludingExits();
+
+  /**
+   * Return 1 if the authority has measured enough MTBF info to use the
+   * MTBF requirement instead of the uptime requirement for assigning the
+   * Stable flag, 0 if not, or -1 if the authority doesn't report this
+   * information.
+   *
+   * @since 1.1.0
+   */
+  public int getEnoughMtbfInfo();
+
+  /**
+   * Return 1 if the authority has enough measured bandwidths that it'll
+   * ignore the advertised bandwidth claims of routers without measured
+   * bandwidth, 0 if not, or -1 if the authority doesn't report this
+   * information.
+   *
+   * @since 1.1.0
+   */
+  public int getIgnoringAdvertisedBws();
+
+  /**
+   * Return status entries for each contained bridge, with map keys being
+   * SHA-1 digests of SHA-1 digest of the bridges' public identity keys,
+   * encoded as 40 upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/BridgePoolAssignment.java b/src/main/java/org/torproject/descriptor/BridgePoolAssignment.java
new file mode 100644
index 0000000..2de4ee9
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/BridgePoolAssignment.java
@@ -0,0 +1,47 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.SortedMap;
+
+/**
+ * Contains a sanitized list of bridges together with the distribution
+ * pools they have been assigned to by the bridge distribution service
+ * BridgeDB.
+ *
+ * <p>BridgeDB receives bridge network statuses
+ * ({@link BridgeNetworkStatus}) from the bridge authority, assigns these
+ * bridges to persistent distribution rings, and hands them out to bridge
+ * users.  BridgeDB periodically dumps the list of running bridges with
+ * information about the rings, subrings, and file buckets to which they
+ * are assigned to a local file.</p>
+ *
+ * <p>Details about sanitizing bridge pool assignments can be found
+ * <a href="https://collector.torproject.org/#type-bridge-pool-assignment";>here</a>.
+ * </p>
+ *
+ * @since 1.0.0
+ */
+public interface BridgePoolAssignment extends Descriptor {
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the entries contained in this bridge pool assignment list
+   * with map keys being SHA-1 digests of SHA-1 digest of the bridges'
+   * public identity keys, encoded as 40 upper-case hexadecimal
+   * characters, and map values being assignment strings, e.g.
+   * {@code "https ring=3 flag=stable"}.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, String> getEntries();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/BridgeServerDescriptor.java b/src/main/java/org/torproject/descriptor/BridgeServerDescriptor.java
new file mode 100644
index 0000000..7d4503f
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/BridgeServerDescriptor.java
@@ -0,0 +1,24 @@
+/* Copyright 2015--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains a sanitized bridge server descriptor.
+ *
+ * <p>Sanitized bridge server descriptors share many contents with relay
+ * server descriptors ({@link RelayServerDescriptor}), which is why they
+ * share a common superinterface ({@link ServerDescriptor}).  The main
+ * purpose of having two subinterfaces is being able to distinguish
+ * descriptor types more easily.</p>
+ *
+ * <p>Details about sanitizing bridge server descriptors can be found
+ * <a href="https://collector.torproject.org/#type-bridge-server-descriptor";>here</a>.
+ * </p>
+ *
+ * @since 1.1.0
+ */
+public interface BridgeServerDescriptor extends ServerDescriptor {
+
+}
+
diff --git a/src/main/java/org/torproject/descriptor/Descriptor.java b/src/main/java/org/torproject/descriptor/Descriptor.java
new file mode 100644
index 0000000..7cad109
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/Descriptor.java
@@ -0,0 +1,39 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+
+/**
+ * Superinterface for any descriptor with access to generic information
+ * about the descriptor.
+ *
+ * @since 1.0.0
+ */
+public interface Descriptor {
+
+  /**
+   * Return the raw descriptor bytes.
+   *
+   * @since 1.0.0
+   */
+  public byte[] getRawDescriptorBytes();
+
+  /**
+   * Return the (possibly empty) list of annotations in the format
+   * {@code "@key( value)*"}.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getAnnotations();
+
+  /**
+   * Return any unrecognized lines when parsing this descriptor, or an
+   * empty list if there were no unrecognized lines.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getUnrecognizedLines();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorCollector.java b/src/main/java/org/torproject/descriptor/DescriptorCollector.java
new file mode 100644
index 0000000..b1027dc
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorCollector.java
@@ -0,0 +1,62 @@
+/* Copyright 2015--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.io.File;
+
+/**
+ * Descriptor source that synchronizes descriptors from the CollecTor
+ * service to a given local directory.
+ *
+ * <p>This type is not a descriptor source in the proper sense, because it
+ * does not produce descriptors by itself.  But it often creates the
+ * prerequisites for reading descriptors from disk using
+ * {@link DescriptorReader}.</p>
+ *
+ * <p>Code sample:</p>
+ * <pre>{@code
+ * DescriptorCollector descriptorCollector =
+ *     DescriptorSourceFactory.createDescriptorCollector();
+ * descriptorCollector.collectDescriptors(
+ *     // Download from Tor's main CollecTor instance,
+ *     "https://collector.torproject.org";,
+ *     // include network status consensuses and relay server descriptors
+ *     new String[] { "/recent/relay-descriptors/consensuses/",
+ *     "/recent/relay-descriptors/server-descriptors/" },
+ *     // regardless of last-modified time,
+ *     0L,
+ *     // write to the local directory called in/,
+ *     new File("in"),
+ *     // and delete extraneous files that do not exist remotely anymore.
+ *     true);
+ * }</pre>
+ *
+ * @since 1.0.0
+ */
+public interface DescriptorCollector {
+
+  /**
+   * Fetch remote files from a CollecTor instance that do not yet exist
+   * locally and possibly delete local files that do not exist remotely
+   * anymore.
+   *
+   * @param collecTorBaseUrl CollecTor base URL without trailing slash,
+   *     e.g., {@code "https://collector.torproject.org"}
+   * @param remoteDirectories Remote directories to collect descriptors
+   *     from, e.g.,
+   *     {@code "/recent/relay-descriptors/server-descriptors/"}, without
+   *     processing subdirectories unless they are explicitly listed
+   * @param minLastModified Minimum last-modified time in milliseconds of
+   *     files to be collected, or 0 for collecting all files
+   * @param localDirectory Directory where collected files will be written
+   * @param deleteExtraneousLocalFiles Whether to delete all local files
+   *     that do not exist remotely anymore
+   *
+   * @since 1.0.0
+   */
+  public void collectDescriptors(String collecTorBaseUrl,
+      String[] remoteDirectories, long minLastModified,
+      File localDirectory, boolean deleteExtraneousLocalFiles);
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorDownloader.java b/src/main/java/org/torproject/descriptor/DescriptorDownloader.java
new file mode 100644
index 0000000..f0b1101
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorDownloader.java
@@ -0,0 +1,198 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.Iterator;
+import java.util.Set;
+
+/**
+ * Descriptor source that downloads relay descriptors from directory
+ * authorities or mirrors.
+ *
+ * <p>Downloading descriptors is done in a batch which starts after
+ * setting any configuration options and initiating the download
+ * process.</p>
+ *
+ * @since 1.0.0
+ */
+public interface DescriptorDownloader {
+
+  /**
+   * Add a directory authority to download descriptors from, which is
+   * only required for downloading network status votes and will be used
+   * when no directory mirrors are available.
+   *
+   * @since 1.0.0
+   */
+  public void addDirectoryAuthority(String nickname, String ip,
+      int dirPort);
+
+  /**
+   * Add a directory mirror to download descriptors from, which is
+   * preferred for downloading descriptors, except for network status
+   * votes which are only available on directory authorities.
+   *
+   * @since 1.0.0
+   */
+  public void addDirectoryMirror(String nickname, String ip, int dirPort);
+
+  /**
+   * Include the current network status consensus in the downloads.
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeCurrentConsensus();
+
+  /**
+   * Include the current network status consensus in the downloads, and
+   * attempt to download it from all directory authorities.
+   *
+   * <p>The primary purpose of doing this is to compare different
+   * consensuses and download characteristics to each other.  Typically,
+   * downloading from a single directory mirror or authority is
+   * sufficient.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeCurrentConsensusFromAllDirectoryAuthorities();
+
+  /**
+   * Include the current network status votes referenced from a
+   * previously downloaded consensus in the downloads, which requires
+   * downloading the current consensus from at least one directory mirror
+   * or authority.
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeCurrentReferencedVotes();
+
+  /**
+   * Include the current network status vote published by the given
+   * directory authority in the downloads, which requires downloading from
+   * at least one directory authority.
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeCurrentVote(String fingerprint);
+
+  /**
+   * Include the current network status votes published by the given
+   * directory authorities in the downloads, which requires downloading
+   * from at least one directory authority.
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeCurrentVotes(Set<String> fingerprints);
+
+  /**
+   * Include all server descriptors referenced from a previously
+   * downloaded network status consensus in the downloads.
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeReferencedServerDescriptors();
+
+  /**
+   * Exclude the server descriptor with the given identifier from the
+   * downloads even if it's referenced from a consensus and we're supposed
+   * to download all referenced server descriptors.
+   *
+   * @since 1.0.0
+   */
+  public void setExcludeServerDescriptor(String identifier);
+
+  /**
+   * Exclude the server descriptors with the given identifiers from the
+   * downloads even if they are referenced from a consensus and we're
+   * supposed to download all referenced server descriptors.
+   *
+   * @since 1.0.0
+   */
+  public void setExcludeServerDescriptors(Set<String> identifier);
+
+  /**
+   * Include all extra-info descriptors referenced from previously
+   * downloaded server descriptors in the downloads.
+   *
+   * @since 1.0.0
+   */
+  public void setIncludeReferencedExtraInfoDescriptors();
+
+  /**
+   * Exclude the extra-info descriptor with the given identifier from the
+   * downloads even if it's referenced from a server descriptor and we're
+   * supposed to download all referenced extra-info descriptors.
+   *
+   * @since 1.0.0
+   */
+  public void setExcludeExtraInfoDescriptor(String identifier);
+
+  /**
+   * Exclude the extra-info descriptors with the given identifiers from
+   * the downloads even if they are referenced from server descriptors
+   * and we're supposed to download all referenced extra-info
+   * descriptors.
+   *
+   * @since 1.0.0
+   */
+  public void setExcludeExtraInfoDescriptors(Set<String> identifiers);
+
+  /**
+   * Define a connect timeout for a single request.
+   *
+   * <p>If a timeout expires, no further requests will be sent to the
+   * directory authority or mirror.  Setting this value to 0 disables the
+   * connect timeout.  Default value is 1 minute (60 * 1000).</p>
+   *
+   * @since 1.0.0
+   */
+  public void setConnectTimeout(long connectTimeoutMillis);
+
+  /**
+   * Define a read timeout for a single request.
+   *
+   * <p>If a timeout expires, no further requests will be sent to the
+   * directory authority or mirror.  Setting this value to 0 disables the
+   * read timeout.  Default value is 1 minute (60 * 1000).</p>
+   *
+   * @since 1.0.0
+   */
+  public void setReadTimeout(long readTimeoutMillis);
+
+  /**
+   * Define a global timeout for all requests.
+   *
+   * <p>Once this timeout expires, all running requests are aborted and no
+   * further requests are made.  Setting this value to 0 disables the
+   * global timeout.  Default is 1 hour (60 * 60 * 1000).</p>
+   *
+   * @since 1.0.0
+   */
+  public void setGlobalTimeout(long globalTimeoutMillis);
+
+  /**
+   * Fail descriptor parsing when encountering an unrecognized line.
+   *
+   * <p>This option is not set by default, because the Tor specifications
+   * allow for new lines to be added that shall be ignored by older Tor
+   * versions.  But some applications may want to handle unrecognized
+   * descriptor lines explicitly.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setFailUnrecognizedDescriptorLines();
+
+  /**
+   * Download the previously configured relay descriptors and make them
+   * available via the returned blocking iterator.
+   *
+   * <p>Whenever the downloader runs out of descriptors and expects to
+   * provide more shortly after, it blocks the caller.  This method can
+   * only be run once.</p>
+   *
+   * @since 1.0.0
+   */
+  public Iterator<DescriptorRequest> downloadDescriptors();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorFile.java b/src/main/java/org/torproject/descriptor/DescriptorFile.java
new file mode 100644
index 0000000..417d7f9
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorFile.java
@@ -0,0 +1,77 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.io.File;
+import java.util.List;
+
+/**
+ * Container for descriptors read from a file.
+ *
+ * <p>When the {@link DescriptorReader} reads descriptors from local files
+ * it provides an iterator over these containers which in turn contain
+ * references to classes implementing the {@link Descriptor} interface.
+ * This container also stores potentially useful meta-data about the
+ * descriptor file.</p>
+ *
+ * @since 1.0.0
+ */
+public interface DescriptorFile {
+
+  /**
+   * Return the directory where this descriptor file was contained, or
+   * null if the file was contained in a tarball.
+   *
+   * @since 1.0.0
+   */
+  public File getDirectory();
+
+  /**
+   * Return the tarball where this descriptor file was contained, or null
+   * if the file was not contained in a tarball.
+   *
+   * @since 1.0.0
+   */
+  public File getTarball();
+
+  /**
+   * Return the descriptor file itself, or null if the descriptor file
+   * was contained in a tarball.
+   *
+   * @since 1.0.0
+   */
+  public File getFile();
+
+  /**
+   * Return the descriptor file name, which is either the absolute path
+   * of the file on disk, or the tar file entry name.
+   *
+   * @since 1.0.0
+   */
+  public String getFileName();
+
+  /**
+   * Return the time in milliseconds since the epoch when the descriptor
+   * file on disk was last modified.
+   *
+   * @since 1.0.0
+   */
+  public long getLastModified();
+
+  /**
+   * Return the descriptors contained in the descriptor file.
+   *
+   * @since 1.0.0
+   */
+  public List<Descriptor> getDescriptors();
+
+  /**
+   * Return the first exception that was thrown when reading this file or
+   * parsing its content, or null if no exception was thrown.
+   *
+   * @since 1.0.0
+   */
+  public Exception getException();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorParseException.java b/src/main/java/org/torproject/descriptor/DescriptorParseException.java
new file mode 100644
index 0000000..309d3f7
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorParseException.java
@@ -0,0 +1,20 @@
+/* Copyright 2014--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Thrown if raw descriptor contents cannot be parsed to one or more
+ * {@link Descriptor} instances, according to descriptor specifications.
+ *
+ * @since 1.0.0
+ */
+@SuppressWarnings("deprecation")
+public class DescriptorParseException
+    extends org.torproject.descriptor.impl.DescriptorParseException {
+  private static final long serialVersionUID = 100L;
+  public DescriptorParseException(String message) {
+    super(message);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorParser.java b/src/main/java/org/torproject/descriptor/DescriptorParser.java
new file mode 100644
index 0000000..680b8b2
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorParser.java
@@ -0,0 +1,47 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+
+/**
+ * Descriptor source that parses descriptors from raw descriptor contents.
+ *
+ * <p>Unlike most of the other descriptor sources this descriptor source
+ * does not operate in a batch-processing mode.  It takes the raw
+ * descriptor contents of one or more descriptors, parses them, and
+ * returns a list of descriptors.</p>
+ *
+ * <p>This descriptor source is internally used by other descriptor
+ * sources but can also be used directly by applications that obtain
+ * raw descriptor contents via other means than one of the existing
+ * descriptor sources.</p>
+ *
+ * @since 1.0.0
+ */
+public interface DescriptorParser {
+
+  /**
+   * Fail descriptor parsing when encountering an unrecognized line.
+   *
+   * <p>This option is not set by default, because the Tor specifications
+   * allow for new lines to be added that shall be ignored by older Tor
+   * versions.  But some applications may want to handle unrecognized
+   * descriptor lines explicitly.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setFailUnrecognizedDescriptorLines(
+      boolean failUnrecognizedDescriptorLines);
+
+  /**
+   * Parse descriptors in the given byte array, possibly parsing the
+   * publication time from the file name, depending on the descriptor
+   * type.
+   *
+   * @since 1.0.0
+   */
+  public List<Descriptor> parseDescriptors(byte[] rawDescriptorBytes,
+      String fileName) throws DescriptorParseException;
+}
diff --git a/src/main/java/org/torproject/descriptor/DescriptorReader.java b/src/main/java/org/torproject/descriptor/DescriptorReader.java
new file mode 100644
index 0000000..771755e
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorReader.java
@@ -0,0 +1,143 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.io.File;
+import java.util.Iterator;
+import java.util.SortedMap;
+
+/**
+ * Descriptor source that reads descriptors from local files and provides
+ * an iterator over parsed descriptors.
+ *
+ * <p>This descriptor source is likely the most widely used one, possibly
+ * in combination with {@link DescriptorCollector} to synchronize
+ * descriptors from the CollecTor service.</p>
+ *
+ * <p>Reading descriptors is done in a batch which starts after setting
+ * any configuration options and initiating the read process.</p>
+ *
+ * <p>Code sample:</p>
+ * <pre>{@code
+ * DescriptorReader descriptorReader =
+ *     DescriptorSourceFactory.createDescriptorReader();
+ * // Read descriptors from local directory called in/.
+ * descriptorReader.addDirectory(new File("in"));
+ * Iterator<DescriptorFile> descriptorFiles =
+ *     descriptorReader.readDescriptors();
+ * while (descriptorFiles.hasNext()) {
+ *   DescriptorFile descriptorFile = descriptorFiles.next();
+ *   for (Descriptor descriptor : descriptorFile.getDescriptors()) {
+ *     if ((descriptor instanceof RelayNetworkStatusConsensus)) {
+ *       // Only process network status consensuses, ignore the rest.
+ *       RelayNetworkStatusConsensus consensus =
+ *           (RelayNetworkStatusConsensus) descriptor;
+ *       processConsensus(consensus);
+ *     }
+ *   }
+ * }}</pre>
+ *
+ * @since 1.0.0
+ */
+public interface DescriptorReader {
+
+  /**
+   * Add a local directory to read descriptors from, which may contain
+   * descriptor files or tarballs containing descriptor files.
+   *
+   * @since 1.0.0
+   */
+  public void addDirectory(File directory);
+
+  /**
+   * Add a tarball to read descriptors from, which may be uncompressed,
+   * bz2-compressed, or xz-compressed.
+   *
+   * @since 1.0.0
+   */
+  public void addTarball(File tarball);
+
+  /**
+   * Exclude files that are listed in the given history file and that
+   * haven't changed since they have last been read.
+   *
+   * <p>Add a new line for each descriptor that is read in this execution
+   * and remove lines for files that don't exist anymore.</p>
+   *
+   * <p>Lines in the history file contain the last modified time in
+   * milliseconds since the epoch and the absolute path of a file.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setExcludeFiles(File historyFile);
+
+  /**
+   * Exclude files if they haven't changed since the corresponding last
+   * modified timestamps.
+   *
+   * <p>Can be used instead of (or in addition to) a history file.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setExcludedFiles(SortedMap<String, Long> excludedFiles);
+
+  /**
+   * Return files and last modified timestamps of files that exist in the
+   * input directory or directories, but that have been excluded from
+   * parsing, because they haven't changed since they were last read.
+   *
+   * <p>Can be used instead of (or in addition to) a history file when
+   * combined with the set of parsed files.</p>
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Long> getExcludedFiles();
+
+  /**
+   * Return files and last modified timestamps of files that exist in the
+   * input directory or directories and that have been parsed.
+   *
+   * <p>Can be used instead of (or in addition to) a history file when
+   * combined with the set of excluded files.</p>
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Long> getParsedFiles();
+
+  /**
+   * Fail descriptor parsing when encountering an unrecognized line.
+   *
+   * <p>This option is not set by default, because the Tor specifications
+   * allow for new lines to be added that shall be ignored by older Tor
+   * versions.  But some applications may want to handle unrecognized
+   * descriptor lines explicitly.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setFailUnrecognizedDescriptorLines();
+
+  /**
+   * Don't keep more than this number of parsed descriptor files in the
+   * queue.
+   *
+   * <p>The default is 100, but if descriptor files contain hundreds or
+   * even thousands of descriptors, that default may be too high.</p>
+   *
+   * @since 1.0.0
+   */
+  public void setMaxDescriptorFilesInQueue(int max);
+
+  /**
+   * Read the previously configured descriptors and make them available
+   * via the returned blocking iterator.
+   *
+   * <p>Whenever the reader runs out of descriptors and expects to provide
+   * more shortly after, it blocks the caller.  This method can only be
+   * run once.</p>
+   *
+   * @since 1.0.0
+   */
+  public Iterator<DescriptorFile> readDescriptors();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorRequest.java b/src/main/java/org/torproject/descriptor/DescriptorRequest.java
new file mode 100644
index 0000000..c36c0c0
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorRequest.java
@@ -0,0 +1,100 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+
+/**
+ * Container for descriptors downloaded from a directory authority or
+ * mirror.
+ *
+ * <p>When the {@link DescriptorDownloader} downloads descriptors from
+ * directory authorities or mirrors it provides an iterator over these
+ * containers which in turn contain references to classes implementing the
+ * {@link Descriptor} interface.  This container also stores potentially
+ * useful meta-data about the descriptor request.</p>
+ *
+ * @since 1.0.0
+ */
+public interface DescriptorRequest {
+
+  /**
+   * Return the request URL that was used in this request.
+   *
+   * @since 1.0.0
+   */
+  public String getRequestUrl();
+
+  /**
+   * Return the nickname of the directory mirror or authority as
+   * previously configured.
+   *
+   * @since 1.0.0
+   */
+  public String getDirectoryNickname();
+
+  /**
+   * Return the first exception that was thrown when making this request
+   * or parsing the response, or null if no exception was thrown.
+   *
+   * @since 1.0.0
+   */
+  public Exception getException();
+
+  /**
+   * Return the response code that the directory mirror or authority
+   * returned.
+   *
+   * @since 1.0.0
+   */
+  public int getResponseCode();
+
+  /**
+   * Return the time in milliseconds since the epoch when this request
+   * was started.
+   *
+   * @since 1.0.0
+   */
+  public long getRequestStart();
+
+  /**
+   * Return the time in milliseconds since the epoch when this request
+   * ended.
+   *
+   * @since 1.0.0
+   */
+  public long getRequestEnd();
+
+  /**
+   * Return whether this request ended, because the connect timeout has
+   * expired.
+   *
+   * @since 1.0.0
+   */
+  public boolean connectTimeoutHasExpired();
+
+  /**
+   * Return whether this request ended, because the read timeout has
+   * expired.
+   *
+   * @since 1.0.0
+   */
+  public boolean readTimeoutHasExpired();
+
+  /**
+   * Return whether this request ended, because the global timeout for
+   * all requests has expired.
+   *
+   * @since 1.0.0
+   */
+  public boolean globalTimeoutHasExpired();
+
+  /**
+   * Return the descriptors contained in the reply.
+   *
+   * @since 1.0.0
+   */
+  public List<Descriptor> getDescriptors();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DescriptorSourceFactory.java b/src/main/java/org/torproject/descriptor/DescriptorSourceFactory.java
new file mode 100644
index 0000000..af13f39
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DescriptorSourceFactory.java
@@ -0,0 +1,187 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Factory for descriptor sources which in turn produce descriptors.
+ *
+ * <p>Descriptor sources are the only producers of classes implementing
+ * the {@link Descriptor} superinterface.  There exist descriptor sources
+ * for obtaining remote descriptor data ({@link DescriptorDownloader} and
+ * {@link DescriptorCollector}) and descriptor sources for processing
+ * local descriptor data ({@link DescriptorReader} and
+ * {@link DescriptorParser}).</p>
+ *
+ * <p>By default, this factory returns implementations from the library's
+ * own impl package.  This may be overridden by setting Java properties,
+ * though most users will simply use the default implementations.</p>
+ *
+ * <p>These properties can be used for setting the implementation:</p>
+ * <ul>
+ *   <li>{@code descriptor.collector}</li>
+ *   <li>{@code descriptor.downloader}</li>
+ *   <li>{@code descriptor.parser}</li>
+ *   <li>{@code descriptor.reader}</li>
+ * </ul>
+ *
+ * <p>Assuming the classpath contains the special implementation
+ * referenced, your application classes as well as a descriptor API jar
+ * the following is an example for using a different implementation of the
+ * descriptor downloader:</p>
+ *
+ * <p><code>
+ *  java -Ddescriptor.downloader=my.special.descriptorimpl.Downloader my.app.Mainclass
+ * </code></p>
+ *
+ * @since 1.0.0
+ */
+public final class DescriptorSourceFactory {
+
+  /**
+   * Default implementation of the {@link DescriptorDownloader}
+   * descriptor source.
+   *
+   * @since 1.0.0
+   */
+  public final static String DOWNLOADER_DEFAULT =
+      "org.torproject.descriptor.impl.DescriptorDownloaderImpl";
+
+  /**
+   * Default implementation of the {@link DescriptorParser} descriptor
+   * source.
+   *
+   * @since 1.0.0
+   */
+  public final static String PARSER_DEFAULT =
+      "org.torproject.descriptor.impl.DescriptorParserImpl";
+
+  /**
+   * Default implementation of the {@link DescriptorReader} descriptor
+   * source.
+   *
+   * @since 1.0.0
+   */
+  public final static String READER_DEFAULT =
+      "org.torproject.descriptor.impl.DescriptorReaderImpl";
+
+  /**
+   * Default implementation of the {@link DescriptorCollector} descriptor
+   * source.
+   *
+   * @since 1.0.0
+   */
+  public final static String COLLECTOR_DEFAULT =
+      "org.torproject.descriptor.impl.DescriptorCollectorImpl";
+
+  /**
+   * Property name for overriding the implementation of the
+   * {@link DescriptorParser} descriptor source, which is by default set
+   * to the class in {@link #PARSER_DEFAULT}.
+   *
+   * @since 1.0.0
+   */
+  public final static String PARSER_PROPERTY = "descriptor.parser";
+
+  /**
+   * Property name for overriding the implementation of the
+   * {@link DescriptorReader} descriptor source, which is by default set
+   * to the class in {@link #READER_DEFAULT}.
+   *
+   * @since 1.0.0
+   */
+  public final static String READER_PROPERTY = "descriptor.reader";
+
+  /**
+   * Property name for overriding the implementation of the
+   * {@link DescriptorDownloader} descriptor source, which is by default
+   * set to the class in {@link #DOWNLOADER_DEFAULT}.
+   *
+   * @since 1.0.0
+   */
+  public final static String DOWNLOADER_PROPERTY =
+      "descriptor.downloader";
+
+  /**
+   * Property name for overriding the implementation of the
+   * {@link DescriptorCollector} descriptor source, which is by default
+   * set to the class in {@link #COLLECTOR_DEFAULT}.
+   *
+   * @since 1.0.0
+   */
+  public final static String COLLECTOR_PROPERTY = "descriptor.collector";
+
+  /**
+   * Create a new {@link DescriptorParser} by instantiating the class in
+   * {@link #PARSER_PROPERTY}.
+   *
+   * @since 1.0.0
+   */
+  public final static DescriptorParser createDescriptorParser() {
+    return (DescriptorParser) retrieve(PARSER_PROPERTY);
+  }
+
+  /**
+   * Create a new {@link DescriptorReader} by instantiating the class in
+   * {@link #READER_PROPERTY}.
+   *
+   * @since 1.0.0
+   */
+  public final static DescriptorReader createDescriptorReader() {
+    return (DescriptorReader) retrieve(READER_PROPERTY);
+  }
+
+  /**
+   * Create a new {@link DescriptorDownloader} by instantiating the class
+   * in {@link #DOWNLOADER_PROPERTY}.
+   *
+   * @since 1.0.0
+   */
+  public final static DescriptorDownloader createDescriptorDownloader() {
+    return (DescriptorDownloader) retrieve(DOWNLOADER_PROPERTY);
+  }
+
+  /**
+   * Create a new {@link DescriptorCollector} by instantiating the class
+   * in {@link #COLLECTOR_PROPERTY}.
+   *
+   * @since 1.0.0
+   */
+  public final static DescriptorCollector createDescriptorCollector() {
+    return (DescriptorCollector) retrieve(COLLECTOR_PROPERTY);
+  }
+
+  private final static <T> Object retrieve(String type) {
+    Object object;
+    String clazzName = null;
+    try {
+      switch (type) {
+      case PARSER_PROPERTY:
+        clazzName = System.getProperty(type, PARSER_DEFAULT);
+        break;
+      case DOWNLOADER_PROPERTY:
+        clazzName = System.getProperty(type, DOWNLOADER_DEFAULT);
+        break;
+      case READER_PROPERTY:
+        clazzName = System.getProperty(type, READER_DEFAULT);
+        break;
+      case COLLECTOR_PROPERTY:
+        clazzName = System.getProperty(type, COLLECTOR_DEFAULT);
+        break;
+      }
+      object = ClassLoader.getSystemClassLoader().loadClass(clazzName).
+          newInstance();
+    } catch (ClassNotFoundException ex) {
+      throw new ImplementationNotAccessibleException("Cannot load class "
+          + clazzName + "for type " + type, ex);
+    } catch (InstantiationException ex) {
+      throw new ImplementationNotAccessibleException("Cannot load class "
+          + clazzName + "for type " + type, ex);
+    } catch (IllegalAccessException ex) {
+      throw new ImplementationNotAccessibleException("Cannot load class "
+          + clazzName + "for type " + type, ex);
+    }
+    return object;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DirSourceEntry.java b/src/main/java/org/torproject/descriptor/DirSourceEntry.java
new file mode 100644
index 0000000..96d81ee
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DirSourceEntry.java
@@ -0,0 +1,96 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains details about an authority and its vote that contributed to a
+ * consensus.
+ *
+ * <p>A directory source entry is not a descriptor type of its own but is
+ * part of a network status consensus
+ * ({@link RelayNetworkStatusConsensus}).</p>
+ *
+ * @since 1.0.0
+ */
+public interface DirSourceEntry {
+
+  /**
+   * Return the raw directory source entry bytes.
+   *
+   * @since 1.0.0
+   */
+  public byte[] getDirSourceEntryBytes();
+
+  /**
+   * Return the authority's nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return a SHA-1 digest of the authority's long-term authority
+   * identity key used for the version 3 directory protocol, encoded as
+   * 40 upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getIdentity();
+
+  /**
+   * Return the authority's hostname.
+   *
+   * @since 1.2.0
+   */
+  public String getHostname();
+
+  /**
+   * Return the authority's primary IPv4 address in dotted-quad format.
+   *
+   * @since 1.0.0
+   */
+  public String getIp();
+
+  /**
+   * Return the TCP port where this authority accepts directory-related
+   * HTTP connections.
+   *
+   * @since 1.0.0
+   */
+  public int getDirPort();
+
+  /**
+   * Return the TCP port where this authority accepts TLS connections for
+   * the main OR protocol.
+   *
+   * @since 1.0.0
+   */
+  public int getOrPort();
+
+  /**
+   * Return whether this directory source entry was created using a
+   * legacy key.
+   *
+   * @since 1.0.0
+   */
+  public boolean isLegacy();
+
+  /**
+   * Return the contact information for this authority, which may contain
+   * non-ASCII characters.
+   *
+   * @since 1.0.0
+   */
+  public String getContactLine();
+
+  /**
+   * Return the SHA-1 vote digest, encoded as 40 lower-case hexadecimal
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getVoteDigest();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DirectoryKeyCertificate.java b/src/main/java/org/torproject/descriptor/DirectoryKeyCertificate.java
new file mode 100644
index 0000000..07211ef
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DirectoryKeyCertificate.java
@@ -0,0 +1,109 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains a key certificate in the version 3 directory protocol.
+ *
+ * <p>Every directory authority in the version 3 directory protocol uses
+ * two keys: a medium-term signing key, and a long-term authority identity
+ * key.  (Authorities also have a relay identity key used in their role as
+ * a relay and by earlier versions of the directory protocol.)  The
+ * identity key is used from time to time to sign new key certificates
+ * containing signing keys.  The contained signing key is used to sign key
+ * certificates and status documents.</p>
+ *
+ * @since 1.0.0
+ */
+public interface DirectoryKeyCertificate extends Descriptor {
+
+  /**
+   * Return the version of this descriptor, which must be 3 or higher.
+   *
+   * @since 1.0.0
+   */
+  public int getDirKeyCertificateVersion();
+
+  /**
+   * Return the authority's primary IPv4 address in dotted-quad format,
+   * or null if the certificate does not contain an address.
+   *
+   * @since 1.0.0
+   */
+  public String getAddress();
+
+  /**
+   * Return the TCP port where this authority accepts directory-related
+   * HTTP connections, or -1 if the certificate does not contain a port.
+   *
+   * @since 1.0.0
+   */
+  public int getPort();
+
+  /**
+   * Return a SHA-1 digest of the authority's long-term authority
+   * identity key used for the version 3 directory protocol, encoded as
+   * 40 upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return the authority's identity key in PEM format.
+   *
+   * @since 1.0.0
+   */
+  public String getDirIdentityKey();
+
+  /**
+   * Return the time in milliseconds since the epoch when the authority's
+   * signing key and this key certificate were generated.
+   *
+   * @since 1.0.0
+   */
+  public long getDirKeyPublishedMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch after which the
+   * authority's signing key is no longer valid.
+   *
+   * @since 1.0.0
+   */
+  public long getDirKeyExpiresMillis();
+
+  /**
+   * Return the authority's signing key in PEM format.
+   *
+   * @since 1.0.0
+   */
+  public String getDirSigningKey();
+
+  /**
+   * Return the signature of the authority's identity key made using the
+   * authority's signing key, or null if the certificate does not contain
+   * such a signature.
+   *
+   * @since 1.0.0
+   */
+  public String getDirKeyCrosscert();
+
+  /**
+   * Return the certificate signature from the initial item
+   * "dir-key-certificate-version" until the final item
+   * "dir-key-certification", signed with the authority identity key.
+   *
+   * @since 1.0.0
+   */
+  public String getDirKeyCertification();
+
+  /**
+   * Return the SHA-1 certificate digest, encoded as 40 lower-case
+   * hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getCertificateDigest();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/DirectorySignature.java b/src/main/java/org/torproject/descriptor/DirectorySignature.java
new file mode 100644
index 0000000..8877a4e
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/DirectorySignature.java
@@ -0,0 +1,52 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains the signature of a network status consensus or vote.
+ *
+ * <p>A directory signature is not a descriptor type of its own but is
+ * part of a network status consensus
+ * ({@link RelayNetworkStatusConsensus}) or vote
+ * ({@link RelayNetworkStatusVote}).</p>
+ *
+ * @since 1.0.0
+ */
+public interface DirectorySignature {
+
+  /**
+   * Return the digest algorithm, which is "sha1" by default and which
+   * can be "sha256" or another digest algorithm.
+   *
+   * @since 1.0.0
+   */
+  public String getAlgorithm();
+
+  /**
+   * Return the SHA-1 digest of the authority's long-term identity key in
+   * the version 3 directory protocol, encoded as 40 upper-case
+   * hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getIdentity();
+
+  /**
+   * Return the SHA-1 digest of the authority's medium-term signing key
+   * in the version 3 directory protocol, encoded as 40 upper-case
+   * hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getSigningKeyDigest();
+
+  /**
+   * Return the directory signature string made with the authority's
+   * identity key in the version 3 directory protocol.
+   *
+   * @since 1.0.0
+   */
+  public String getSignature();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/ExitList.java b/src/main/java/org/torproject/descriptor/ExitList.java
new file mode 100644
index 0000000..2a5cb2e
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/ExitList.java
@@ -0,0 +1,92 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Contains an exit list containing the IP addresses of relays that the
+ * exit list service TorDNSEL found when exiting through them.
+ *
+ * @since 1.0.0
+ */
+public interface ExitList extends Descriptor {
+
+  /**
+   * End-of-line character expected in exit lists.
+   *
+   * @since 1.0.0
+   */
+  public final static String EOL = "\n";
+
+  /**
+   * Exit list entry containing results from a single exit scan.
+   *
+   * @since 1.1.0
+   */
+  public interface Entry {
+
+    /**
+     * Return the scanned relay's fingerprint, which is a SHA-1 digest of
+     * the relays's public identity key, encoded as 40 upper-case
+     * hexadecimal characters.
+     *
+     * @since 1.1.0
+     */
+    public String getFingerprint();
+
+    /**
+     * Return the time in milliseconds since the epoch when the scanned
+     * relay's last known descriptor was published.
+     *
+     * @since 1.1.0
+     */
+    public long getPublishedMillis();
+
+    /**
+     * Return the time in milliseconds since the epoch when the network
+     * status that this scan was based on was published.
+     *
+     * @since 1.1.0
+     */
+    public long getLastStatusMillis();
+
+    /**
+     * Return the IP addresses that were determined in the scan with map
+     * keys being IPv4 addresses in dotted-quad format and map values
+     * being scan times in milliseconds since the epoch.
+     *
+     * @since 1.1.0
+     */
+    public Map<String, Long> getExitAddresses();
+  }
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was downloaded.
+   *
+   * @since 1.0.0
+   */
+  public long getDownloadedMillis();
+
+  /**
+   * Return the unordered set of exit scan results.
+   *
+   * @since 1.0.0
+   * @deprecated The {@link ExitListEntry} type has been deprecated and
+   *     superseded by {@link ExitList.Entry} which is returned by
+   *     {@link #getEntries()}.
+   */
+  @Deprecated
+  public Set<ExitListEntry> getExitListEntries();
+
+  /**
+   * Return the unordered set of exit scan results.
+   *
+   * @since 1.1.0
+   */
+  public Set<ExitList.Entry> getEntries();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/ExitListEntry.java b/src/main/java/org/torproject/descriptor/ExitListEntry.java
new file mode 100644
index 0000000..2a3d79f
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/ExitListEntry.java
@@ -0,0 +1,55 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Exit list entry containing results from a single exit scan.
+ *
+ * @since 1.0.0
+ * @deprecated Superseded by {@link ExitList.Entry}.
+ */
+@Deprecated
+public interface ExitListEntry extends ExitList.Entry {
+
+  /**
+   * Return the scanned relay's fingerprint, which is a SHA-1 digest of
+   * the relays's public identity key, encoded as 40 upper-case
+   * hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return the time in milliseconds since the epoch when the scanned
+   * relay's last known descriptor was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the network
+   * status that this scan was based on was published.
+   *
+   * @since 1.0.0
+   */
+  public long getLastStatusMillis();
+
+  /**
+   * Return the IPv4 address in dotted-quad format that was determined in
+   * the scan.
+   *
+   * @since 1.0.0
+   */
+  public String getExitAddress();
+
+  /**
+   * Return the scan time in milliseconds since the epoch.
+   *
+   * @since 1.0.0
+   */
+  public long getScanMillis();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/ExtraInfoDescriptor.java b/src/main/java/org/torproject/descriptor/ExtraInfoDescriptor.java
new file mode 100644
index 0000000..49efbf3
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/ExtraInfoDescriptor.java
@@ -0,0 +1,646 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+/**
+ * Contains a relay or sanitized bridge extra-info descriptor.
+ *
+ * <p>Relays publish extra-info descriptors as an addendum to server
+ * descriptors ({@link ServerDescriptor}) to report extraneous information
+ * to the directory authorities that clients do not need to download in
+ * order to function.  This information primarily consists of statistics
+ * gathered by the relay about its usage and can take up a lot of
+ * descriptor space.  The separation of server descriptors and extra-info
+ * descriptors has become less relevant with the introduction of
+ * microdescriptors ({@link Microdescriptor}) that are derived from server
+ * descriptors by the directory authority and which clients download
+ * instead of server descriptors, but it persists.</p>
+ *
+ * <p>Bridges publish extra-info descriptors to the bridge authority for
+ * the same reason, to include statistics about their usage without
+ * increasing the directory protocol overhead for bridge clients.  In this
+ * case, the separation of server descriptors and extra-info descriptors
+ * is slightly more relevant, because there are no microdescriptors for
+ * bridges, so that bridge clients still download server descriptors of
+ * bridges they're using.  Another reason is that bridges need to include
+ * information like details of all the transports they support in their
+ * descriptors, and bridge clients using one such transport are not
+ * supposed to learn the details of the other transports.</p>
+ *
+ * <p>It's worth noting that all contents of extra-info descriptors are
+ * written and signed by relays and bridges without a third party
+ * verifying their correctness.  The (bridge) directory authorities may
+ * decide to exclude dishonest servers from the network statuses they
+ * produce, but that wouldn't be reflected in extra-info descriptors.</p>
+ * 
+ * @since 1.0.0
+ */
+public interface ExtraInfoDescriptor extends Descriptor {
+
+  /**
+   * Return the SHA-1 descriptor digest, encoded as 40 lower-case (relay
+   * descriptors) or upper-case (bridge descriptors) hexadecimal
+   * characters, that is used to reference this descriptor from a server
+   * descriptor.
+   *
+   * @since 1.0.0
+   */
+  public String getExtraInfoDigest();
+
+  /**
+   * Return the SHA-256 descriptor digest, encoded as 43 base64
+   * characters without padding characters, that may be used to reference
+   * this descriptor from a server descriptor.
+   *
+   * @since 1.1.0
+   */
+  public String getExtraInfoDigestSha256();
+
+  /**
+   * Return the server's nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return a SHA-1 digest of the server's public identity key, encoded
+   * as 40 upper-case hexadecimal characters, that is typically used to
+   * uniquely identify the server.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * and the corresponding server descriptor were generated.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the server's history of read bytes, or null if the descriptor
+   * does not contain a bandwidth history; older Tor versions included
+   * bandwidth histories in their server descriptors
+   * ({@link ServerDescriptor#getReadHistory()}).
+   *
+   * @since 1.0.0
+   */
+  public BandwidthHistory getReadHistory();
+
+  /**
+   * Return the server's history of written bytes, or null if the
+   * descriptor does not contain a bandwidth history; older Tor versions
+   * included bandwidth histories in their server descriptors
+   * ({@link ServerDescriptor#getWriteHistory()}).
+   *
+   * @since 1.0.0
+   */
+  public BandwidthHistory getWriteHistory();
+
+  /**
+   * Return a SHA-1 digest of the GeoIP database file used by this server
+   * to resolve client IP addresses to country codes, encoded as 40
+   * upper-case hexadecimal characters, or null if no GeoIP database
+   * digest is included.
+   *
+   * @since 1.0.0
+   */
+  public String getGeoipDbDigest();
+
+  /**
+   * Return a SHA-1 digest of the GeoIPv6 database file used by this
+   * server to resolve client IP addresses to country codes, encoded as 40
+   * upper-case hexadecimal characters, or null if no GeoIPv6 database
+   * digest is included.
+   *
+   * @since 1.0.0
+   */
+  public String getGeoip6DbDigest();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * directory request statistics interval ended, or -1 if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getDirreqStatsEndMillis();
+
+  /**
+   * Return the interval length of the included directory request
+   * statistics in seconds, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getDirreqStatsIntervalLength();
+
+  /**
+   * Return statistics on unique IP addresses requesting v2 network
+   * statuses with map keys being country codes and map values being
+   * numbers of unique IP addresses rounded up to the nearest multiple of
+   * 8, or null if no such statistics are included (which is the case with
+   * recent Tor versions).
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV2Ips();
+
+  /**
+   * Return statistics on unique IP addresses requesting v3 network
+   * status consensuses of any flavor with map keys being country codes
+   * and map values being numbers of unique IP addresses rounded up to the
+   * nearest multiple of 8, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV3Ips();
+
+  /**
+   * Return statistics on directory requests for v2 network statuses with
+   * map keys being country codes and map values being request numbers
+   * rounded up to the nearest multiple of 8, or null if no such
+   * statistics are included (which is the case with recent Tor
+   * versions).
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV2Reqs();
+
+  /**
+   * Return statistics on directory requests for v3 network status
+   * consensuses of any flavor with map keys being country codes and map
+   * values being request numbers rounded up to the nearest multiple of 8,
+   * or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV3Reqs();
+
+  /**
+   * Return the share of requests for v2 network statuses that the server
+   * expects to receive from clients, or -1.0 if this share is not
+   * included (which is the case with recent Tor versions).
+   *
+   * @since 1.0.0
+   */
+  public double getDirreqV2Share();
+
+  /**
+   * Return the share of requests for v3 network status consensuses of
+   * any flavor that the server expects to receive from clients, or -1.0
+   * if this share is not included (which is the case with recent Tor
+   * versions).
+   *
+   * @since 1.0.0
+   */
+  public double getDirreqV3Share();
+
+  /**
+   * Return statistics on responses to directory requests for v2 network
+   * statuses with map keys being response strings and map values being
+   * response numbers rounded up to the nearest multiple of 4, or null if
+   * no such statistics are included (which is the case with recent Tor
+   * versions).
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV2Resp();
+
+  /**
+   * Return statistics on responses to directory requests for v3 network
+   * status consensuses of any flavor with map keys being response strings
+   * and map values being response numbers rounded up to the nearest
+   * multiple of 4, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV3Resp();
+
+  /**
+   * Return statistics on directory requests for v2 network statuses to
+   * the server's directory port with map keys being statistic keys and
+   * map values being statistic values like counts or quantiles, or null
+   * if no such statistics are included (which is the case with recent Tor
+   * versions).
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV2DirectDl();
+
+  /**
+   * Return statistics on directory requests for v3 network status
+   * consensuses of any flavor to the server's directory port with map
+   * keys being statistic keys and map values being statistic values like
+   * counts or quantiles, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV3DirectDl();
+
+  /**
+   * Return statistics on directory requests for v2 network statuses
+   * tunneled through a circuit with map keys being statistic keys and map
+   * values being statistic values, or null if no such statistics are
+   * included (which is the case with recent Tor versions).
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV2TunneledDl();
+
+  /**
+   * Return statistics on directory requests for v3 network status
+   * consensuses of any flavor tunneled through a circuit with map keys
+   * being statistic keys and map values being statistic values, or null
+   * if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getDirreqV3TunneledDl();
+
+  /**
+   * Return the directory request read history contained in this
+   * descriptor, or null if no such history is contained.
+   *
+   * @since 1.0.0
+   */
+  public BandwidthHistory getDirreqReadHistory();
+
+  /**
+   * Return the directory request write history contained in this
+   * descriptor, or null if no such history is contained.
+   *
+   * @since 1.0.0
+   */
+  public BandwidthHistory getDirreqWriteHistory();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * entry statistics interval ended, or -1 if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public long getEntryStatsEndMillis();
+
+  /**
+   * Return the interval length of the included entry statistics in
+   * seconds, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getEntryStatsIntervalLength();
+
+  /**
+   * Return statistics on client IP addresses with map keys being country
+   * codes and map values being the number of unique IP addresses that
+   * have connected from that country rounded up to the nearest multiple
+   * of 8, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getEntryIps();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * cell statistics interval ended, or -1 if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public long getCellStatsEndMillis();
+
+  /**
+   * Return the interval length of the included cell statistics in
+   * seconds, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getCellStatsIntervalLength();
+
+  /**
+   * Return the mean number of processed cells per circuit by circuit
+   * decile starting with the loudest decile at index 0 and the quietest
+   * decile at index 8, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public List<Integer> getCellProcessedCells();
+
+  /**
+   * Return the mean number of cells contained in circuit queues by
+   * circuit decile starting with the loudest decile at index 0 and the
+   * quietest decile at index 8, or null if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public List<Double> getCellQueuedCells();
+
+  /**
+   * Return the mean times in milliseconds that cells spend in circuit
+   * queues by circuit decile starting with the loudest decile at index 0
+   * and the quietest decile at index 8, or null if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public List<Integer> getCellTimeInQueue();
+
+  /**
+   * Return the mean number of circuits included in any of the cell
+   * statistics deciles, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public int getCellCircuitsPerDecile();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * statistics on bi-directional connection usage ended, or -1 if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getConnBiDirectStatsEndMillis();
+
+  /**
+   * Return the interval length of the included statistics on
+   * bi-directional connection usage in seconds, or -1 if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getConnBiDirectStatsIntervalLength();
+
+  /**
+   * Return the number of connections on which this server read and wrote
+   * less than 2 KiB/s in a 10-second interval, or -1 if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public int getConnBiDirectBelow();
+
+  /**
+   * Return the number of connections on which this server read and wrote
+   * at least 2 KiB/s in a 10-second interval and at least 10 times more
+   * in read direction than in write direction, or -1 if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public int getConnBiDirectRead();
+
+  /**
+   * Return the number of connections on which this server read and wrote
+   * at least 2 KiB/s in a 10-second interval and at least 10 times more
+   * in write direction than in read direction, or -1 if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public int getConnBiDirectWrite();
+
+  /**
+   * Return the number of connections on which this server read and wrote
+   * at least 2 KiB/s in a 10-second interval but not 10 times more in
+   * either direction, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public int getConnBiDirectBoth();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * exit statistics interval ended, or -1 if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public long getExitStatsEndMillis();
+
+  /**
+   * Return the interval length of the included exit statistics in
+   * seconds, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getExitStatsIntervalLength();
+
+  /**
+   * Return statistics on KiB written to streams exiting the Tor network
+   * by target TCP port with map keys being string representations of
+   * ports (or {@code "other"}) and map values being KiB rounded up to the
+   * next full KiB, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Long> getExitKibibytesWritten();
+
+  /**
+   * Return statistics on KiB read from streams exiting the Tor network
+   * by target TCP port with map keys being string representations of
+   * ports (or {@code "other"}) and map values being KiB rounded up to the
+   * next full KiB, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Long> getExitKibibytesRead();
+
+  /**
+   * Return statistics on opened streams exiting the Tor network by
+   * target TCP port with map keys being string representations of ports
+   * (or {@code "other"}) and map values being the number of opened
+   * streams, rounded up to the nearest multiple of 4, or null if no such
+   * statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Long> getExitStreamsOpened();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * "geoip" statistics interval started, or -1 if no such statistics are
+   * included (which is the case except for very old Tor versions).
+   *
+   * @since 1.0.0
+   */
+  public long getGeoipStartTimeMillis();
+
+  /**
+   * Return statistics on the origin of client IP addresses with map keys
+   * being country codes and map values being the number of unique IP
+   * addresses that have connected from that country between the start of
+   * the statistics interval and the descriptor publication time rounded
+   * up to the nearest multiple of 8, or null if no such statistics are
+   * included (which is the case except for very old Tor versions).
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getGeoipClientOrigins();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * bridge statistics interval ended, or -1 if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public long getBridgeStatsEndMillis();
+
+  /**
+   * Return the interval length of the included bridge statistics in
+   * seconds, or -1 if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public long getBridgeStatsIntervalLength();
+
+  /**
+   * Return statistics on bridge client IP addresses by country with map
+   * keys being country codes and map values being the number of unique IP
+   * addresses that have connected from that country rounded up to the
+   * nearest multiple of 8, or null if no such statistics are included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getBridgeIps();
+
+  /**
+   * Return statistics on bridge client IP addresses by IP version with
+   * map keys being protocol families, e.g., {@code "v4"} or {@code "v6"},
+   * and map values being the number of unique IP addresses rounded up to
+   * the nearest multiple of 8, or null if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getBridgeIpVersions();
+
+  /**
+   * Return statistics on bridge client IP addresses by transport with
+   * map keys being pluggable transport names, e.g., {@code "obfs2"} or
+   * {@code "obfs3"} for known transports, {@code "<OR>"} for the default
+   * onion routing protocol, or {@code "<??>"} for an unknown transport,
+   * and map values being the number of unique IP addresses rounded up to
+   * the nearest multiple of 8, or null if no such statistics are
+   * included.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getBridgeIpTransports();
+
+  /**
+   * Return the (possibly empty) list of pluggable transports supported
+   * by this server.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getTransports();
+
+  /**
+   * Return the time in milliseconds since the epoch when the included
+   * hidden-service statistics interval ended, or -1 if no such statistics
+   * are included.
+   *
+   * @since 1.1.0
+   */
+  public long getHidservStatsEndMillis();
+
+  /**
+   * Return the interval length of the included hidden-service statistics
+   * in seconds, or -1 if no such statistics are included.
+   *
+   * @since 1.1.0
+   */
+  public long getHidservStatsIntervalLength();
+
+  /**
+   * Return the approximate number of RELAY cells seen in either
+   * direction on a circuit after receiving and successfully processing a
+   * RENDEZVOUS1 cell, or null if no such statistics are included.
+   *
+   * @since 1.1.0
+   */
+  public Double getHidservRendRelayedCells();
+
+  /**
+   * Return the obfuscation parameters applied to the original
+   * measurement value of RELAY cells seen in either direction on a
+   * circuit after receiving and successfully processing a RENDEZVOUS1
+   * cell, or null if no such statistics are included.
+   *
+   * @since 1.1.0
+   */
+  public Map<String, Double> getHidservRendRelayedCellsParameters();
+
+  /**
+   * Return the approximate number of unique hidden-service identities
+   * seen in descriptors published to and accepted by this hidden-service
+   * directory, or null if no such statistics are included.
+   *
+   * @since 1.1.0
+   */
+  public Double getHidservDirOnionsSeen();
+
+  /**
+   * Return the obfuscation parameters applied to the original
+   * measurement value of unique hidden-service identities seen in
+   * descriptors published to and accepted by this hidden-service
+   * directory, or null if no such statistics are included.
+   *
+   * @since 1.1.0
+   */
+  public Map<String, Double> getHidservDirOnionsSeenParameters();
+
+  /**
+   * Return the RSA-1024 signature of the PKCS1-padded descriptor digest,
+   * taken from the beginning of the router line through the newline after
+   * the router-signature line, or null if the descriptor doesn't contain
+   * a signature (which is the case in sanitized bridge descriptors).
+   *
+   * @since 1.1.0
+   */
+  public String getRouterSignature();
+
+  /**
+   * Return the Ed25519 certificate in PEM format, or null if the
+   * descriptor doesn't contain one.
+   *
+   * @since 1.1.0
+   */
+  public String getIdentityEd25519();
+
+  /**
+   * Return the Ed25519 master key, encoded as 43 base64 characters
+   * without padding characters, which was either parsed from the optional
+   * {@code "master-key-ed25519"} line or derived from the (likewise
+   * optional) Ed25519 certificate following the
+   * {@code "identity-ed25519"} line, or null if the descriptor contains
+   * neither Ed25519 master key nor Ed25519 certificate.
+   *
+   * @since 1.1.0
+   */
+  public String getMasterKeyEd25519();
+
+  /**
+   * Return the Ed25519 signature of the SHA-256 digest of the entire
+   * descriptor, encoded as 86 base64 characters without padding
+   * characters, from the first character up to and including the first
+   * space after the {@code "router-sig-ed25519"} string, prefixed with
+   * the string {@code "Tor router descriptor signature v1"}.
+   *
+   * @since 1.1.0
+   */
+  public String getRouterSignatureEd25519();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/ImplementationNotAccessibleException.java b/src/main/java/org/torproject/descriptor/ImplementationNotAccessibleException.java
new file mode 100644
index 0000000..c54e48f
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/ImplementationNotAccessibleException.java
@@ -0,0 +1,22 @@
+/* Copyright 2014--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Thrown if a descriptor source implementation class cannot be found,
+ * instantiated, or accessed.
+ *
+ * @see DescriptorSourceFactory
+ * @since 1.0.0
+ */
+@SuppressWarnings("serial")
+public class ImplementationNotAccessibleException
+    extends RuntimeException {
+
+  public ImplementationNotAccessibleException(String string,
+      Throwable ex) {
+    super(string, ex);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/Microdescriptor.java b/src/main/java/org/torproject/descriptor/Microdescriptor.java
new file mode 100644
index 0000000..f19b7df
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/Microdescriptor.java
@@ -0,0 +1,135 @@
+/* Copyright 2014--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+
+/**
+ * Contains a relay microdescriptor.
+ *
+ * <p>A microdescriptor is a stripped-down version of a relay server
+ * descriptor ({@link RelayServerDescriptor}) generated by the directory
+ * authorities by extracting and/or transforming relay server descriptor
+ * contents following strict rules without adding the authority's opinion
+ * about the relay.  Microdescriptors are referenced from microdescriptor
+ * consensuses ({@link RelayNetworkStatusConsensus}) and downloaded by
+ * clients to make path-selection decisions and to build circuits.
+ * Microdescriptors contain only the most relevant parts that clients care
+ * about.  Microdescriptors are expected to be relatively static and only
+ * change about once per week.</p>
+ *
+ * @since 1.0.0
+ */
+public interface Microdescriptor extends Descriptor {
+
+  /**
+   * Return the SHA-256 descriptor digest, encoded as 43 base64
+   * characters without padding characters, that is used to reference this
+   * descriptor from a vote or microdescriptor consensus.
+   *
+   * @since 1.0.0
+   */
+  public String getMicrodescriptorDigest();
+
+  /**
+   * Return the RSA-1024 public key in PEM format used to encrypt CREATE
+   * cells for this server, or null if the descriptor doesn't contain an
+   * onion key.
+   *
+   * @since 1.0.0
+   */
+  public String getOnionKey();
+
+  /**
+   * Return the curve25519 public key, encoded as 43 base64 characters
+   * without padding characters, that is used for the ntor circuit
+   * extended handshake, or null if the descriptor didn't contain an
+   * ntor-onion-key line.
+   *
+   * @since 1.0.0
+   */
+  public String getNtorOnionKey();
+
+  /**
+   * Return IP addresses and TCP ports where this server accepts TLS
+   * connections for the main OR protocol, or an empty list if the server
+   * does not support additional addresses or ports; entries are given in
+   * the order as they are listed in the descriptor; IPv4 addresses are
+   * given in dotted-quad format, IPv6 addresses use the colon-separated
+   * hexadecimal format surrounded by square brackets, and TCP ports are
+   * separated from the IP address using a colon.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getOrAddresses();
+
+  /**
+   * Return nicknames, $-prefixed identity fingerprints, or tuples of the
+   * format {@code $fingerprint=nickname} or {@code $fingerprint~nickname}
+   * of servers contained in this server's family, or null if the
+   * descriptor does not contain a family line.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getFamilyEntries();
+
+  /**
+   * Return the default policy, {@code "accept"} or {@code "reject"}, of
+   * the IPv4 port summary, or null if the descriptor didn't contain an
+   * IPv4 exit-policy summary line which is equivalent to rejecting all
+   * streams to IPv4 targets.
+   *
+   * @since 1.0.0
+   */
+  public String getDefaultPolicy();
+
+  /**
+   * Return the port list of the IPv4 exit-policy summary, or null if the
+   * descriptor didn't contain an IPv4 exit-policy summary line which is
+   * equivalent to rejecting all streams to IPv4 targets.
+   *
+   * @since 1.0.0
+   */
+  public String getPortList();
+
+  /**
+   * Return the default policy, {@code "accept"} or {@code "reject"}, of
+   * the IPv6 port summary, or null if the descriptor didn't contain an
+   * IPv6 exit-policy summary line which is equivalent to rejecting all
+   * streams to IPv6 targets.
+   *
+   * @since 1.0.0
+   */
+  public String getIpv6DefaultPolicy();
+
+  /**
+   * Return the port list of the IPv6 exit-policy summary, or null if the
+   * descriptor didn't contain an IPv6 exit-policy summary line which is
+   * equivalent to rejecting all streams to IPv6 targets.
+   *
+   * @since 1.0.0
+   */
+  public String getIpv6PortList();
+
+  /**
+   * Return a SHA-1 digest of the server's RSA-1024 identity key, encoded
+   * as 27 base64 characters without padding characters, that is only
+   * included to prevent collisions between microdescriptors, or null if
+   * no such digest is included.
+   *
+   * @since 1.1.0
+   */
+  public String getRsa1024Identity();
+
+  /**
+   * Return a SHA-256 digest of the server's Ed25519 identity key,
+   * encoded as 43 base64 characters without padding characters, that is
+   * only included to prevent collisions between microdescriptors, or null
+   * if no such digest is included.
+   *
+   * @since 1.1.0
+   */
+  public String getEd25519Identity();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/NetworkStatusEntry.java b/src/main/java/org/torproject/descriptor/NetworkStatusEntry.java
new file mode 100644
index 0000000..43b3175
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/NetworkStatusEntry.java
@@ -0,0 +1,177 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+
+/**
+ * Contains an entry in a network status in the version 2 or 3 directory
+ * protocol or in a bridge network status.
+ *
+ * <p>A network status entry is not a descriptor type of its own but is
+ * part of a network status in the version 2 directory protocol
+ * ({@link RelayNetworkStatus}), a vote ({@link RelayNetworkStatusVote})
+ * or flavored/unflavored consensus (@link RelayNetworkStatusConsensus})
+ * in the version 3 directory protocol, or a bridge network status
+ * ({@link BridgeNetworkStatus}).  Entries in signed directories in the
+ * version 1 directory protocol are represented by router status entries
+ * ({@link RouterStatusEntry}).</p>
+ *
+ * @since 1.0.0
+ */
+public interface NetworkStatusEntry {
+
+  /**
+   * Return the raw network status entry bytes.
+   *
+   * @since 1.0.0
+   */
+  public byte[] getStatusEntryBytes();
+
+  /**
+   * Return the server nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return a SHA-1 digest of the server's identity key, encoded as 40
+   * upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return the SHA-1 digest of the server descriptor, or null if the
+   * containing network status does not contain server descriptor
+   * references, like a microdesc consensus.
+   *
+   * @since 1.0.0
+   */
+  public String getDescriptor();
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the server's primary IPv4 address in dotted-quad format.
+   *
+   * @since 1.0.0
+   */
+  public String getAddress();
+
+  /**
+   * Return the TCP port where this server accepts TLS connections for
+   * the main OR protocol.
+   *
+   * @since 1.0.0
+   */
+  public int getOrPort();
+
+  /**
+   * Return the TCP port where this server accepts directory-related HTTP
+   * connections.
+   *
+   * @since 1.0.0
+   */
+  public int getDirPort();
+
+  /**
+   * Return the (possibly empty) set of microdescriptor digests if the
+   * containing network status is a vote or microdesc consensus, or null
+   * otherwise.
+   *
+   * @since 1.0.0
+   */
+  public Set<String> getMicrodescriptorDigests();
+
+  /**
+   * Return additional IP addresses and TCP ports where this server
+   * accepts TLS connections for the main OR protocol, or an empty list if
+   * the network status doesn't contain any such additional addresses and
+   * ports.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getOrAddresses();
+
+  /**
+   * Return the relay flags assigned to this server, or null if the
+   * status entry didn't contain any relay flags.
+   *
+   * @since 1.0.0
+   */
+  public SortedSet<String> getFlags();
+
+  /**
+   * Return the Tor software version, or null if the status entry didn't
+   * contain version information.
+   *
+   * @since 1.0.0
+   */
+  public String getVersion();
+
+  /**
+   * Return the bandwidth weight of this server or -1 if the status entry
+   * didn't contain a bandwidth line.
+   *
+   * @since 1.0.0
+   */
+  public long getBandwidth();
+
+  /**
+   * Return the measured bandwidth or -1 if the status entry either
+   * didn't contain bandwidth information or didn't contain an indication
+   * that this information is based on measured bandwidth.
+   *
+   * @since 1.0.0
+   */
+  public long getMeasured();
+
+  /**
+   * Return whether the status entry is yet unmeasured by the bandwidth
+   * authorities; only included in consensuses using method 17 or higher.
+   *
+   * @since 1.0.0
+   */
+  public boolean getUnmeasured();
+
+  /**
+   * Return the default policy of the port summary, which can be either
+   * {@code "accept"} or {@code "reject"}, or null if the status entry
+   * didn't contain an exit policy summary.
+   *
+   * @since 1.0.0
+   */
+  public String getDefaultPolicy();
+
+  /**
+   * Return the list of ports or port intervals of the exit port summary,
+   * or null if the status entry didn't contain an exit policy summary.
+   *
+   * @since 1.0.0
+   */
+  public String getPortList();
+
+  /**
+   * Return the server's Ed25519 master key, encoded as 43 base64
+   * characters without padding characters, "none" if the relay doesn't
+   * have an Ed25519 identity, or null if the status entry didn't contain
+   * this information or if the status is not a vote.
+   *
+   * @since 1.1.0
+   */
+  public String getMasterKeyEd25519();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RelayDirectory.java b/src/main/java/org/torproject/descriptor/RelayDirectory.java
new file mode 100644
index 0000000..8f3e58b
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RelayDirectory.java
@@ -0,0 +1,104 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+
+/**
+ * Contains a signed directory in the version 1 directory protocol.
+ *
+ * <p>Directory authorities in the (long outdated) version 1 of the
+ * directory protocol served signed directory documents containing a list
+ * of signed server descriptors ({@link ServerDescriptor}) along with
+ * short summaries of the status of each server
+ * ({@link RouterStatusEntry}).</p>
+ *
+ * <p>Clients in that version of the directory protocol would fetch this
+ * signed directory to get up-to-date information on the state of the
+ * network and be certain that the list was attested by a trusted
+ * directory authority.</p>
+ *
+ * <p>Signed directories in the version 1 directory protocol have first
+ * been superseded by network status documents in the version 2 directory
+ * protocol ({@link RelayNetworkStatus}) and later by network status
+ * consensuses ({@link RelayNetworkStatusConsensus}) in the version 3
+ * directory protocol.</p> 
+ *
+ * @since 1.0.0
+ */
+public interface RelayDirectory extends Descriptor {
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the RSA-1024 public key in PEM format used by this authority
+   * as long-term identity key and to sign network statuses, or null if
+   * this key is not included in the descriptor header.
+   *
+   * @since 1.0.0
+   */
+  public String getDirSigningKey();
+
+  /**
+   * Return recommended Tor versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedSoftware();
+
+  /**
+   * Return the directory signature string made with the authority's
+   * identity key.
+   *
+   * @since 1.0.0
+   */
+  public String getDirectorySignature();
+
+  /**
+   * Return router status entries, one for each contained relay.
+   *
+   * @since 1.0.0
+   */
+  public List<RouterStatusEntry> getRouterStatusEntries();
+
+  /**
+   * Return a list of server descriptors contained in the signed
+   * directory.
+   *
+   * @since 1.0.0
+   */
+  public List<ServerDescriptor> getServerDescriptors();
+
+  /**
+   * Return a (very likely empty) list of exceptions from parsing the
+   * contained server descriptors.
+   *
+   * @since 1.0.0
+   */
+  public List<Exception> getServerDescriptorParseExceptions();
+
+  /**
+   * Return the directory nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return the SHA-1 directory digest, encoded as 40 lower-case
+   * hexadecimal characters, that the directory authority used to sign the
+   * directory.
+   *
+   * @since 1.0.0
+   */
+  public String getDirectoryDigest();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RelayExtraInfoDescriptor.java b/src/main/java/org/torproject/descriptor/RelayExtraInfoDescriptor.java
new file mode 100644
index 0000000..73f8438
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RelayExtraInfoDescriptor.java
@@ -0,0 +1,21 @@
+/* Copyright 2015--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains a relay extra-info descriptor.
+ *
+ * <p>Relay extra-info descriptors share many contents with sanitized
+ * bridge extra-info descriptors ({@link BridgeExtraInfoDescriptor}),
+ * which is why they share a common superinterface
+ * ({@link ExtraInfoDescriptor}).  The main purpose of having two
+ * subinterfaces is being able to distinguish descriptor types more
+ * easily.</p>
+ *
+ * @since 1.1.0
+ */
+public interface RelayExtraInfoDescriptor extends ExtraInfoDescriptor {
+
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RelayNetworkStatus.java b/src/main/java/org/torproject/descriptor/RelayNetworkStatus.java
new file mode 100644
index 0000000..db3ddac
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RelayNetworkStatus.java
@@ -0,0 +1,176 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+import java.util.SortedMap;
+import java.util.SortedSet;
+
+/**
+ * Contains a network status document in the version 2 directory protocol.
+ *
+ * <p>Directory authorities in the (outdated) version 2 of the directory
+ * protocol published signed network status documents.  Each network
+ * status listed, for every relay in the network
+ * ({@link NetworkStatusEntry}): a hash of its identity key, a hash of its
+ * most recent server descriptor, and a summary of what the authority
+ * believed about its status.</p>
+ *
+ * <p>Clients would download the authorities' network status documents in
+ * turn, and believe statements about routers iff they were attested to by
+ * more than half of the authorities.</p>
+ *
+ * <p>Network status documents in the version 2 directory protocol
+ * supersede signed directories in the version 1 directory protocol
+ * ({@link RelayDirectory}) and have been superseded by network status
+ * consensuses ({@link RelayNetworkStatusConsensus}) in the version 3
+ * directory protocol.</p>
+ *
+ * @since 1.0.0
+ */
+public interface RelayNetworkStatus extends Descriptor {
+
+  /**
+   * Return the document format version of this descriptor which is 2.
+   *
+   * @since 1.0.0
+   */
+  public int getNetworkStatusVersion();
+
+  /**
+   * Return the authority's hostname.
+   *
+   * @since 1.0.0
+   */
+  public String getHostname();
+
+  /**
+   * Return the authority's primary IPv4 address in dotted-quad format,
+   * or null if the descriptor does not contain an address.
+   *
+   * @since 1.0.0
+   */
+  public String getAddress();
+
+  /**
+   * Return the TCP port where this authority accepts directory-related
+   * HTTP connections, or 0 if the authority does not accept such
+   * connections.
+   *
+   * @since 1.0.0
+   */
+  public int getDirport();
+
+  /**
+   * Return a SHA-1 digest of the authority's public identity key,
+   * encoded as 40 upper-case hexadecimal characters, which is also used
+   * to sign network statuses.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return the contact information for this authority, which may contain
+   * non-ASCII characters.
+   *
+   * @since 1.0.0
+   */
+  public String getContactLine();
+
+  /**
+   * Return the RSA-1024 public key in PEM format used by this authority
+   * as long-term identity key and to sign network statuses.
+   *
+   * @since 1.0.0
+   */
+  public String getDirSigningKey();
+
+  /**
+   * Return recommended Tor versions for server usage, or null if the
+   * authority does not recommend server versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedServerVersions();
+
+  /**
+   * Return recommended Tor versions for client usage, or null if the
+   * authority does not recommend client versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedClientVersions();
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the set of flags that this directory assigns to relays, or
+   * null if the status does not assign such flags.
+   *
+   * @since 1.0.0
+   */
+  public SortedSet<String> getDirOptions();
+
+  /**
+   * Return status entries for each contained server, with map keys being
+   * SHA-1 digests of the servers' public identity keys, encoded as 40
+   * upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
+
+  /**
+   * Return whether a status entry with the given relay fingerprint
+   * (SHA-1 digest of the server's public identity key, encoded as 40
+   * upper-case hexadecimal characters) exists; convenience method for
+   * {@code getStatusEntries().containsKey(fingerprint)}.
+   *
+   * @since 1.0.0
+   */
+  public boolean containsStatusEntry(String fingerprint);
+
+  /**
+   * Return a status entry by relay fingerprint (SHA-1 digest of the
+   * server's public identity key, encoded as 40 upper-case hexadecimal
+   * characters), or null if no such status entry exists; convenience
+   * method for {@code getStatusEntries().get(fingerprint)}.
+   *
+   * @since 1.0.0
+   */
+  public NetworkStatusEntry getStatusEntry(String fingerprint);
+
+  /**
+   * Return the authority's nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return the directory signature string made with the authority's
+   * identity key.
+   *
+   * @since 1.0.0
+   */
+  public String getDirectorySignature();
+
+  /**
+   * Return the SHA-1 status digest, encoded as 40 lower-case hexadecimal
+   * characters, that the directory authority used to sign the network
+   * status.
+   *
+   * @since 1.0.0
+   */
+  public String getStatusDigest();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RelayNetworkStatusConsensus.java b/src/main/java/org/torproject/descriptor/RelayNetworkStatusConsensus.java
new file mode 100644
index 0000000..15fdaca
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RelayNetworkStatusConsensus.java
@@ -0,0 +1,223 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+import java.util.SortedMap;
+import java.util.SortedSet;
+
+/**
+ * Contains a network status consensus in the version 3 directory protocol.
+ *
+ * <p>Directory authorities in the version 3 of the directory protocol
+ * periodically generate a view of the current descriptors and status for
+ * known relays and send a signed summary of this view to the other
+ * authorities ({@link RelayNetworkStatusVote}).  The authorities compute
+ * the result of this vote and sign a network status consensus containing
+ * the result of the vote, which is this document.</p>
+ *
+ * <p>Clients use consensus documents to find out when their list of
+ * relays is out-of-date by looking at the contained network status
+ * entries ({@link NetworkStatusEntry}).  If it is, they download any
+ * missing server descriptors ({@link ServerDescriptor}).</p>
+ *
+ * @since 1.0.0
+ */
+public interface RelayNetworkStatusConsensus extends Descriptor {
+
+  /**
+   * Return the document format version of this descriptor which is 3 or
+   * higher.
+   *
+   * @since 1.0.0
+   */
+  public int getNetworkStatusVersion();
+
+  /**
+   * Return the consensus flavor name, which denotes the variant of the
+   * original, unflavored consensus, encoded as a string of alphanumeric
+   * characters and dashes, or null if this descriptor is the unflavored
+   * consensus.
+   *
+   * @since 1.0.0
+   */
+  public String getConsensusFlavor();
+
+  /**
+   * Return the consensus method number of this descriptor, which is the
+   * highest consensus method supported by more than 2/3 of voting
+   * authorities, or 0 if no consensus method is contained in the
+   * descriptor.
+   *
+   * @since 1.0.0
+   */
+  public int getConsensusMethod();
+
+  /**
+   * Return the time in milliseconds since the epoch at which this
+   * descriptor became valid.
+   *
+   * @since 1.0.0
+   */
+  public long getValidAfterMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch until which this
+   * descriptor is the freshest that is available.
+   *
+   * @since 1.0.0
+   */
+  public long getFreshUntilMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch until which this
+   * descriptor was valid.
+   *
+   * @since 1.0.0
+   */
+  public long getValidUntilMillis();
+
+  /**
+   * Return the number of seconds that the directory authorities will
+   * allow to collect votes from the other authorities when producing the
+   * next consensus.
+   *
+   * @since 1.0.0
+   */
+  public long getVoteSeconds();
+
+  /**
+   * Return the number of seconds that the directory authorities will
+   * allow to collect signatures from the other authorities when producing
+   * the next consensus.
+   *
+   * @since 1.0.0
+   */
+  public long getDistSeconds();
+
+  /**
+   * Return recommended Tor versions for server usage, or null if the
+   * consensus does not contain an opinion about server versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedServerVersions();
+
+  /**
+   * Return recommended Tor versions for client usage, or null if the
+   * consensus does not contain an opinion about client versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedClientVersions();
+
+  /**
+   * Return a list of software packages and their versions together with a
+   * URL and one or more digests in the format <code>PackageName Version
+   * URL DIGESTS</code> that are known by at least three directory
+   * authorities and agreed upon by the majority of directory authorities,
+   * or null if the consensus does not contain package information.
+   *
+   * @since 1.3.0
+   */
+  public List<String> getPackageLines();
+
+  /**
+   * Return known relay flags in this descriptor that were contained in
+   * enough votes for this consensus to be an authoritative opinion for
+   * these relay flags.
+   *
+   * @since 1.0.0
+   */
+  public SortedSet<String> getKnownFlags();
+
+  /**
+   * Return consensus parameters contained in this descriptor with map
+   * keys being case-sensitive parameter identifiers and map values being
+   * parameter values, or null if the consensus doesn't contain consensus
+   * parameters.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getConsensusParams();
+
+  /**
+   * Return directory source entries for each directory authority that
+   * contributed to the consensus, with map keys being SHA-1 digests of
+   * the authorities' identity keys in the version 3 directory protocol,
+   * encoded as 40 upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, DirSourceEntry> getDirSourceEntries();
+
+  /**
+   * Return status entries for each contained server, with map keys being
+   * SHA-1 digests of the servers' public identity keys, encoded as 40
+   * upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
+
+  /**
+   * Return whether a status entry with the given relay fingerprint
+   * (SHA-1 digest of the server's public identity key, encoded as 40
+   * upper-case hexadecimal characters) exists; convenience method for
+   * {@code getStatusEntries().containsKey(fingerprint)}.
+   *
+   * @since 1.0.0
+   */
+  public boolean containsStatusEntry(String fingerprint);
+
+  /**
+   * Return a status entry by relay fingerprint (SHA-1 digest of the
+   * server's public identity key, encoded as 40 upper-case hexadecimal
+   * characters), or null if no such status entry exists; convenience
+   * method for {@code getStatusEntries().get(fingerprint)}.
+   *
+   * @since 1.0.0
+   */
+  public NetworkStatusEntry getStatusEntry(String fingerprint);
+
+  /**
+   * Return directory signatures of this consensus, with map keys being
+   * SHA-1 digests of the authorities' identity keys in the version 3
+   * directory protocol, encoded as 40 upper-case hexadecimal characters.
+   *
+   * @deprecated Replaced by {@link #getSignatures()} which permits an
+   * arbitrary number of signatures made by an authority using the same
+   * identity key digest and different algorithms.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, DirectorySignature> getDirectorySignatures();
+
+  /**
+   * Return the list of signatures contained in this consensus.
+   *
+   * @since 1.3.0
+   */
+  public List<DirectorySignature> getSignatures();
+
+  /**
+   * Return optional weights to be applied to router bandwidths during
+   * path selection with map keys being case-sensitive weight identifiers
+   * and map values being weight values, or null if the consensus doesn't
+   * contain such weights.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getBandwidthWeights();
+
+  /**
+   * Return the SHA-1 digest of this consensus, encoded as 40 upper-case
+   * hexadecimal characters that directory authorities use to sign the
+   * consensus.
+   *
+   * @since 1.0.0
+   */
+  public String getConsensusDigest();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RelayNetworkStatusVote.java b/src/main/java/org/torproject/descriptor/RelayNetworkStatusVote.java
new file mode 100644
index 0000000..1f77db6
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RelayNetworkStatusVote.java
@@ -0,0 +1,408 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+import java.util.SortedMap;
+import java.util.SortedSet;
+
+/**
+ * Contains a network status vote in the version 3 directory protocol.
+ *
+ * <p>Directory authorities in the version 3 of the directory protocol
+ * periodically generate a view of the current descriptors and status for
+ * known relays and send a signed summary of this view to the other
+ * authorities, which is this document.  The authorities compute the
+ * result of this vote and sign a network status consensus containing the
+ * result of the vote ({@link RelayNetworkStatusConsensus}).</p>
+ *
+ * @since 1.0.0
+ */
+public interface RelayNetworkStatusVote extends Descriptor {
+
+  /**
+   * Return the document format version of this descriptor which is 3 or
+   * higher.
+   *
+   * @since 1.0.0
+   */
+  public int getNetworkStatusVersion();
+
+  /**
+   * Return the list of consensus method numbers supported by this
+   * authority, or null if the descriptor doesn't say so, which would mean
+   * that only method 1 is supported.
+   *
+   * @since 1.0.0
+   */
+  public List<Integer> getConsensusMethods();
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * was published.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch at which the
+   * consensus is supposed to become valid.
+   *
+   * @since 1.0.0
+   */
+  public long getValidAfterMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch until which the
+   * consensus is supposed to be the freshest that is available.
+   *
+   * @since 1.0.0
+   */
+  public long getFreshUntilMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch until which the
+   * consensus is supposed to be valid.
+   *
+   * @since 1.0.0
+   */
+  public long getValidUntilMillis();
+
+  /**
+   * Return the number of seconds that the directory authorities will
+   * allow to collect votes from the other authorities when producing the
+   * next consensus.
+   *
+   * @since 1.0.0
+   */
+  public long getVoteSeconds();
+
+  /**
+   * Return the number of seconds that the directory authorities will
+   * allow to collect signatures from the other authorities when producing
+   * the next consensus.
+   *
+   * @since 1.0.0
+   */
+  public long getDistSeconds();
+
+  /**
+   * Return recommended Tor versions for server usage, or null if the
+   * authority does not recommend server versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedServerVersions();
+
+  /**
+   * Return recommended Tor versions for client usage, or null if the
+   * authority does not recommend client versions.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getRecommendedClientVersions();
+
+  /**
+   * Return a list of software packages and their versions together with a
+   * URL and one or more digests in the format <code>PackageName Version
+   * URL DIGESTS</code> that are known by this directory authority, or
+   * null if this descriptor does not contain package information.
+   *
+   * @since 1.3.0
+   */
+  public List<String> getPackageLines();
+
+  /**
+   * Return known relay flags by this authority.
+   *
+   * @since 1.0.0
+   */
+  public SortedSet<String> getKnownFlags();
+
+  /**
+   * Return the minimum uptime in seconds that this authority requires
+   * for assigning the Stable flag, or -1 if the authority doesn't report
+   * this value.
+   *
+   * @since 1.0.0
+   */
+  public long getStableUptime();
+
+  /**
+   * Return the minimum MTBF (mean time between failure) that this
+   * authority requires for assigning the Stable flag, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.0.0
+   */
+  public long getStableMtbf();
+
+  /**
+   * Return the minimum bandwidth that this authority requires for
+   * assigning the Fast flag, or -1 if the authority doesn't report this
+   * value.
+   *
+   * @since 1.0.0
+   */
+  public long getFastBandwidth();
+
+  /**
+   * Return the minimum WFU (weighted fractional uptime) in percent that
+   * this authority requires for assigning the Guard flag, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.0.0
+   */
+  public double getGuardWfu();
+
+  /**
+   * Return the minimum weighted time in seconds that this authority
+   * needs to know about a relay before assigning the Guard flag, or -1 if
+   * the authority doesn't report this information.
+   *
+   * @since 1.0.0
+   */
+  public long getGuardTk();
+
+  /**
+   * Return the minimum bandwidth that this authority requires for
+   * assigning the Guard flag if exits can be guards, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.0.0
+   */
+  public long getGuardBandwidthIncludingExits();
+
+  /**
+   * Return the minimum bandwidth that this authority requires for
+   * assigning the Guard flag if exits can not be guards, or -1 if the
+   * authority doesn't report this value.
+   *
+   * @since 1.0.0
+   */
+  public long getGuardBandwidthExcludingExits();
+
+  /**
+   * Return 1 if the authority has measured enough MTBF info to use the
+   * MTBF requirement instead of the uptime requirement for assigning the
+   * Stable flag, 0 if not, or -1 if the authority doesn't report this
+   * information.
+   *
+   * @since 1.0.0
+   */
+  public int getEnoughMtbfInfo();
+
+  /**
+   * Return 1 if the authority has enough measured bandwidths that it'll
+   * ignore the advertised bandwidth claims of routers without measured
+   * bandwidth, 0 if not, or -1 if the authority doesn't report this
+   * information.
+   *
+   * @since 1.1.0
+   */
+  public int getIgnoringAdvertisedBws();
+
+  /**
+   * Return consensus parameters contained in this descriptor with map
+   * keys being case-sensitive parameter identifiers and map values being
+   * parameter values, or null if the authority doesn't include consensus
+   * parameters in its vote.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, Integer> getConsensusParams();
+
+  /**
+   * Return the authority's nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return a SHA-1 digest of the authority's long-term authority
+   * identity key used for the version 3 directory protocol, encoded as
+   * 40 upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getIdentity();
+
+  /**
+   * Return the authority's hostname.
+   *
+   * @since 1.2.0
+   */
+  public String getHostname();
+
+  /**
+   * Return the authority's primary IPv4 address in dotted-quad format,
+   * or null if the descriptor does not contain an address.
+   *
+   * @since 1.0.0
+   */
+  public String getAddress();
+
+  /**
+   * Return the TCP port where this authority accepts directory-related
+   * HTTP connections, or 0 if the authority does not accept such
+   * connections.
+   *
+   * @since 1.0.0
+   */
+  public int getDirport();
+
+  /**
+   * Return the TCP port where this authority accepts TLS connections for
+   * the main OR protocol, or 0 if the authority does not accept such
+   * connections.
+   *
+   * @since 1.0.0
+   */
+  public int getOrport();
+
+  /**
+   * Return the contact information for this authority, which may contain
+   * non-ASCII characters, or null if no contact information is included
+   * in the descriptor.
+   *
+   * @since 1.0.0
+   */
+  public String getContactLine();
+
+  /**
+   * Return the version of the directory key certificate used by this
+   * authority, which must be 3 or higher.
+   *
+   * @since 1.0.0
+   */
+  public int getDirKeyCertificateVersion();
+
+  /**
+   * Return the SHA-1 digest for an obsolete authority identity key still
+   * used by this authority to keep older clients working, or null if this
+   * authority does not use such a key.
+   *
+   * @since 1.0.0
+   */
+  public String getLegacyDirKey();
+
+  /**
+   * Return the authority's identity key in PEM format.
+   *
+   * @since 1.2.0
+   */
+  public String getDirIdentityKey();
+
+  /**
+   * Return the time in milliseconds since the epoch when the authority's
+   * signing key and corresponding key certificate were generated.
+   *
+   * @since 1.0.0
+   */
+  public long getDirKeyPublishedMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch after which the
+   * authority's signing key is no longer valid.
+   *
+   * @since 1.0.0
+   */
+  public long getDirKeyExpiresMillis();
+
+  /**
+   * Return the authority's signing key in PEM format.
+   *
+   * @since 1.2.0
+   */
+  public String getDirSigningKey();
+
+  /**
+   * Return the SHA-1 digest of the authority's signing key, encoded as
+   * 40 upper-case hexadecimal characters, or null if this digest cannot
+   * be obtained from the directory signature.
+   *
+   * @deprecated Removed in order to be more explicit that authorities may
+   *     use different digest algorithms than "sha1"; see
+   *     {@link #getSignatures()} and
+   *     {@link DirectorySignature#getSigningKeyDigest()} for
+   *     alternatives.
+   *
+   * @since 1.0.0
+   */
+  public String getSigningKeyDigest();
+
+  /**
+   * Return the signature of the authority's identity key made using the
+   * authority's signing key, or null if the vote does not contain such a
+   * signature.
+   *
+   * @since 1.2.0
+   */
+  public String getDirKeyCrosscert();
+
+  /**
+   * Return the certificate signature from the initial item
+   * "dir-key-certificate-version" until the final item
+   * "dir-key-certification", signed with the authority identity key.
+   *
+   * @since 1.2.0
+   */
+  public String getDirKeyCertification();
+
+  /**
+   * Return status entries for each contained server, with map keys being
+   * SHA-1 digests of the servers' public identity keys, encoded as 40
+   * upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
+
+  /**
+   * Return whether a status entry with the given relay fingerprint
+   * (SHA-1 digest of the server's public identity key, encoded as 40
+   * upper-case hexadecimal characters) exists; convenience method for
+   * {@code getStatusEntries().containsKey(fingerprint)}.
+   *
+   * @since 1.0.0
+   */
+  public boolean containsStatusEntry(String fingerprint);
+
+  /**
+   * Return a status entry by relay fingerprint (SHA-1 digest of the
+   * server's public identity key, encoded as 40 upper-case hexadecimal
+   * characters), or null if no such status entry exists; convenience
+   * method for {@code getStatusEntries().get(fingerprint)}.
+   *
+   * @since 1.0.0
+   */
+  public NetworkStatusEntry getStatusEntry(String fingerprint);
+
+  /**
+   * Return the directory signature of this vote, with the single map key
+   * being the SHA-1 digest of the authority's identity key in the version
+   * 3 directory protocol, encoded as 40 upper-case hexadecimal
+   * characters.
+   *
+   * @deprecated Replaced by {@link #getSignatures()} which permits an
+   * arbitrary number of signatures made by the authority using the same
+   * identity key digest and different algorithms.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<String, DirectorySignature> getDirectorySignatures();
+
+  /**
+   * Return a list of signatures contained in this vote, which is
+   * typically a single signature made by the authority but which may also
+   * be more than one signature made with different keys or algorithms.
+   *
+   * @since 1.3.0
+   */
+  public List<DirectorySignature> getSignatures();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RelayServerDescriptor.java b/src/main/java/org/torproject/descriptor/RelayServerDescriptor.java
new file mode 100644
index 0000000..6ef3140
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RelayServerDescriptor.java
@@ -0,0 +1,20 @@
+/* Copyright 2015--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains a relay server descriptor.
+ *
+ * <p>Relay server descriptors share many contents with sanitized bridge
+ * server descriptors ({@link BridgeServerDescriptor}), which is why they
+ * share a common superinterface ({@link ServerDescriptor}).  The main
+ * purpose of having two subinterfaces is being able to distinguish
+ * descriptor types more easily.</p>
+ *
+ * @since 1.1.0
+ */
+public interface RelayServerDescriptor extends ServerDescriptor {
+
+}
+
diff --git a/src/main/java/org/torproject/descriptor/RouterStatusEntry.java b/src/main/java/org/torproject/descriptor/RouterStatusEntry.java
new file mode 100644
index 0000000..f9a56db
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/RouterStatusEntry.java
@@ -0,0 +1,51 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+/**
+ * Contains a router status entry contained in a signed directory in the
+ * version 1 directory protocol.
+ *
+ * <p>Directory authorities in the (long outdated) version 1 of the
+ * directory protocol included router status entries with short summaries
+ * of the status of each server in the signed directories they produced
+ * ({@link RelayDirectory}).  These entries contained references to server
+ * descriptors published by relays together with the authorities' opinion
+ * on whether relays were verified and live.</p>
+ *
+ * @since 1.0.0
+ */
+public interface RouterStatusEntry {
+
+  /**
+   * Return the relay nickname consisting of 1 to 19 alphanumeric
+   * characters, or null if the relay is unverified.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return a SHA-1 digest of the relay's identity key, encoded as 40
+   * upper-case hexadecimal characters.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return whether the relay is verified.
+   *
+   * @since 1.0.0
+   */
+  public boolean isVerified();
+
+  /**
+   * Return whether the relay is live.
+   *
+   * @since 1.0.0
+   */
+  public boolean isLive();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/ServerDescriptor.java b/src/main/java/org/torproject/descriptor/ServerDescriptor.java
new file mode 100644
index 0000000..d1af421
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/ServerDescriptor.java
@@ -0,0 +1,435 @@
+/* Copyright 2011--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+
+/**
+ * Contains a relay or sanitized bridge server descriptor.
+ *
+ * <p>Relays publish server descriptors to the directory authorities to
+ * register in the network.  Server descriptors contain information about
+ * the capabilities of a server, like their exit policy, that clients use
+ * to select servers for their circuits (along with information provided
+ * by directory authorities on reachability, stability, and capacity of
+ * servers).  Server descriptors also contain network addresses and
+ * cryptographic material that clients use to build circuits.</p>
+ *
+ * <p>Prior to the introduction of microdescriptors
+ * ({@link Microdescriptor}), the directory authorities included
+ * cryptographic digests of server descriptors in network statuses
+ * ({@link RelayNetworkStatusConsensus}) and clients downloaded all
+ * referenced server descriptors.  Nowadays, the directory authorities
+ * derive microdescriptors from server descriptors and reference those
+ * in network statuses, and clients only download microdescriptors instead
+ * of server descriptors.</p>
+ *
+ * <p>Bridges publish server descriptors to the bridge directory
+ * authority, also to announce themselves in the network.  The bridge
+ * directory authority compiles a list of available bridges
+ * ({@link BridgeNetworkStatus}) for the bridge distribution service
+ * BridgeDB.  There are no microdescriptors for bridges, so that bridge
+ * clients still rely on downloading bridge server descriptors directly
+ * from the bridge they're connecting to.</p>
+ *
+ * <p>It's worth noting that all contents of server descriptors are
+ * written and signed by relays and bridges without a third party
+ * verifying their correctness.  The (bridge) directory authorities may
+ * decide to exclude dishonest servers from the network statuses they
+ * produce, but that wouldn't be reflected in server descriptors.</p>
+ *
+ * @since 1.0.0
+ */
+public interface ServerDescriptor extends Descriptor {
+
+  /**
+   * Return the SHA-1 descriptor digest, encoded as 40 lower-case (relay
+   * descriptors) or upper-case (bridge descriptors) hexadecimal
+   * characters, that is used to reference this descriptor from a network
+   * status descriptor.
+   *
+   * @since 1.0.0
+   */
+  public String getServerDescriptorDigest();
+
+  /**
+   * Return the SHA-256 descriptor digest, encoded as 43 base64
+   * characters without padding characters, that may be used to reference
+   * this server descriptor from a network status descriptor.
+   *
+   * @since 1.1.0
+   */
+  public String getServerDescriptorDigestSha256();
+
+  /**
+   * Return the server's nickname consisting of 1 to 19 alphanumeric
+   * characters.
+   *
+   * @since 1.0.0
+   */
+  public String getNickname();
+
+  /**
+   * Return the server's primary IPv4 address in dotted-quad format.
+   *
+   * @since 1.0.0
+   */
+  public String getAddress();
+
+  /**
+   * Return the TCP port where this server accepts TLS connections for
+   * the main OR protocol, or 0 if the server does not accept such
+   * connections.
+   *
+   * @since 1.0.0
+   */
+  public int getOrPort();
+
+  /**
+   * Return the TCP port where this server accepts SOCKS connections,
+   * which is deprecated and should always be 0.
+   *
+   * @since 1.0.0
+   */
+  public int getSocksPort();
+
+  /**
+   * Return the TCP port where this server accepts directory-related HTTP
+   * connections, or 0 if the server does not accept such connections.
+   *
+   * @since 1.0.0
+   */
+  public int getDirPort();
+
+  /**
+   * Return IP addresses and TCP ports where this server accepts TLS
+   * connections for the main OR protocol, or an empty list if the server
+   * does not support additional addresses or ports; entries are given in
+   * the order as they are listed in the descriptor; IPv4 addresses are
+   * given in dotted-quad format, IPv6 addresses use the colon-separated
+   * hexadecimal format surrounded by square brackets, and TCP ports are
+   * separated from the IP address using a colon.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getOrAddresses();
+
+  /**
+   * Return the average bandwidth in bytes per second that the server is
+   * willing to sustain over long periods.
+   *
+   * @since 1.0.0
+   */
+  public int getBandwidthRate();
+
+  /**
+   * Return the burst bandwidth in bytes per second that the server is
+   * willing to sustain in very short intervals.
+   *
+   * @since 1.0.0
+   */
+  public int getBandwidthBurst();
+
+  /**
+   * Return the observed bandwidth in bytes per second as an estimate of
+   * the capacity that the server can handle, or -1 if the descriptor
+   * doesn't contain an observed bandwidth value (which is the case for
+   * Tor 0.0.8 or older).
+   *
+   * @since 1.0.0
+   */
+  public int getBandwidthObserved();
+
+  /**
+   * Return a human-readable string describing the Tor software version
+   * and the operating system of this server, which may contain non-ASCII
+   * characters, typically written as {@code "Tor $version on $system"},
+   * or null if this descriptor does not contain a platform line.
+   *
+   * @since 1.0.0
+   */
+  public String getPlatform();
+
+  /**
+   * Return the time in milliseconds since the epoch when this descriptor
+   * and the corresponding extra-info descriptor were generated.
+   *
+   * @since 1.0.0
+   */
+  public long getPublishedMillis();
+
+  /**
+   * Return a SHA-1 digest of the server's public identity key, encoded
+   * as 40 upper-case hexadecimal characters (without spaces after every 4
+   * characters as opposed to the encoding in the descriptor), that is
+   * typically used to uniquely identify the server, or null if this
+   * descriptor does not contain a fingerprint line.
+   *
+   * @since 1.0.0
+   */
+  public String getFingerprint();
+
+  /**
+   * Return whether the server was hibernating when this descriptor was
+   * published and should not be used to build circuits.
+   *
+   * @since 1.0.0
+   */
+  public boolean isHibernating();
+
+  /**
+   * Return the number of seconds that the server process has been
+   * running (which might even be negative in a few descriptors due to a
+   * bug that was fixed in Tor 0.1.2.7-alpha), or null if the descriptor
+   * does not contain an uptime line.
+   *
+   * @since 1.0.0
+   */
+  public Long getUptime();
+
+  /**
+   * Return the RSA-1024 public key in PEM format used to encrypt CREATE
+   * cells for this server, or null if the descriptor doesn't contain an
+   * onion key (which is the case in sanitized bridge descriptors).
+   *
+   * @since 1.0.0
+   */
+  public String getOnionKey();
+
+  /**
+   * Return the RSA-1024 public key in PEM format used by this server as
+   * long-term identity key, or null if the descriptor doesn't contain a
+   * signing key (which is the case in sanitized bridge descriptors).
+   *
+   * @since 1.0.0
+   */
+  public String getSigningKey();
+
+  /**
+   * Return the server's exit policy consisting of one or more accept or
+   * reject rules that the server follows when deciding whether to allow a
+   * new stream to a given IP address and TCP port.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getExitPolicyLines();
+
+  /**
+   * Return the RSA-1024 signature of the PKCS1-padded descriptor digest,
+   * taken from the beginning of the router line through the newline after
+   * the router-signature line, or null if the descriptor doesn't contain
+   * a signature (which is the case in sanitized bridge descriptors).
+   *
+   * @since 1.0.0
+   */
+  public String getRouterSignature();
+
+  /**
+   * Return the contact information for this server, which may contain
+   * non-ASCII characters, or null if no contact information is included
+   * in the descriptor.
+   *
+   * @since 1.0.0
+   */
+  public String getContact();
+
+  /**
+   * Return nicknames, $-prefixed identity fingerprints, or tuples of the
+   * format {@code $fingerprint=nickname} or {@code $fingerprint~nickname}
+   * of servers contained in this server's family, or null if the
+   * descriptor does not contain a family line.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getFamilyEntries();
+
+  /**
+   * Return the server's history of read bytes, or null if the descriptor
+   * does not contain a bandwidth history; current Tor versions include
+   * bandwidth histories in their extra-info descriptors
+   * ({@link ExtraInfoDescriptor#getReadHistory()}), not in their server
+   * descriptors.
+   *
+   * @since 1.0.0
+   */
+  public BandwidthHistory getReadHistory();
+
+  /**
+   * Return the server's history of written bytes, or null if the
+   * descriptor does not contain a bandwidth history; current Tor versions
+   * include bandwidth histories in their extra-info descriptors
+   * ({@link ExtraInfoDescriptor#getWriteHistory()}), not in their server
+   * descriptors.
+   *
+   * @since 1.0.0
+   */
+  public BandwidthHistory getWriteHistory();
+
+  /**
+   * Return true if the server uses the enhanced DNS logic, or false if
+   * doesn't use it or doesn't include an eventdns line in its
+   * descriptor; current Tor versions should be presumed to have the evdns
+   * backend.
+   *
+   * @since 1.0.0
+   */
+  public boolean getUsesEnhancedDnsLogic();
+
+  /**
+   * Return whether this server is a directory cache that provides
+   * extra-info descriptors.
+   *
+   * @since 1.0.0
+   */
+  public boolean getCachesExtraInfo();
+
+  /**
+   * Return the SHA-1 digest of the server's extra-info descriptor,
+   * encoded as 40 upper-case hexadecimal characters, or null if the
+   * server did not upload a corresponding extra-info descriptor.
+   *
+   * @since 1.0.0
+   */
+  public String getExtraInfoDigest();
+
+  /**
+   * Return the SHA-256 digest of the server's extra-info descriptor,
+   * encoded as 43 base64 characters without padding characters, or null
+   * if the server either did not upload a corresponding extra-info
+   * descriptor or did not refer to it using a SHA-256 digest.
+   *
+   * @since 1.1.0
+   */
+  public String getExtraInfoDigestSha256();
+
+  /**
+   * Return the list of hidden service descriptor version numbers that
+   * this server stores and serves, or null if it doesn't store and serve
+   * any hidden service descriptors.
+   *
+   * @since 1.0.0
+   */
+  public List<Integer> getHiddenServiceDirVersions();
+
+  /**
+   * Return the list of link protocol versions that this server
+   * supports.
+   *
+   * @since 1.0.0
+   */
+  public List<Integer> getLinkProtocolVersions();
+
+  /**
+   * Return the list of circuit protocol versions that this server
+   * supports.
+   *
+   * @since 1.0.0
+   */
+  public List<Integer> getCircuitProtocolVersions();
+
+  /**
+   * Return whether this server allows single-hop circuits to make exit
+   * connections.
+   *
+   * @since 1.0.0
+   */
+  public boolean getAllowSingleHopExits();
+
+  /**
+   * Return the default policy, {@code "accept"} or {@code "reject"}, of
+   * the IPv6 port summary, or null if the descriptor didn't contain an
+   * IPv6 exit-policy summary line which is equivalent to rejecting all
+   * streams to IPv6 targets.
+   *
+   * @since 1.0.0
+   */
+  public String getIpv6DefaultPolicy();
+
+  /**
+   * Return the port list of the IPv6 exit-policy summary, or null if the
+   * descriptor didn't contain an IPv6 exit-policy summary line which is
+   * equivalent to rejecting all streams to IPv6 targets.
+   *
+   * @since 1.0.0
+   */
+  public String getIpv6PortList();
+
+  /**
+   * Return the curve25519 public key, encoded as 43 base64 characters
+   * without padding characters, that is used for the ntor circuit
+   * extended handshake, or null if the descriptor didn't contain an
+   * ntor-onion-key line. */
+  public String getNtorOnionKey();
+
+  /**
+   * Return the Ed25519 certificate in PEM format, or null if the
+   * descriptor doesn't contain one.
+   *
+   * @since 1.1.0
+   */
+  public String getIdentityEd25519();
+
+  /**
+   * Return the Ed25519 master key, encoded as 43 base64 characters
+   * without padding characters, which was either parsed from the optional
+   * {@code "master-key-ed25519"} line or derived from the (likewise
+   * optional) Ed25519 certificate following the
+   * {@code "identity-ed25519"} line, or null if the descriptor contains
+   * neither Ed25519 master key nor Ed25519 certificate.
+   *
+   * @since 1.1.0
+   */
+  public String getMasterKeyEd25519();
+
+  /**
+   * Return the Ed25519 signature of the SHA-256 digest of the entire
+   * descriptor, encoded as 86 base64 characters without padding
+   * characters, from the first character up to and including the first
+   * space after the {@code "router-sig-ed25519"} string, prefixed with
+   * the string {@code "Tor router descriptor signature v1"}.
+   *
+   * @since 1.1.0
+   */
+  public String getRouterSignatureEd25519();
+
+  /**
+   * Return an RSA-1024 signature in PEM format, generated using the
+   * server's onion key, that proves that the party creating the
+   * descriptor had control over the private key corresponding to the
+   * onion key, or null if the descriptor does not contain such a
+   * signature.
+   *
+   * @since 1.1.0
+   */
+  public String getOnionKeyCrosscert();
+
+  /**
+   * Return an Ed25519 signature in PEM format, generated using the
+   * server's ntor onion key, that proves that the party creating the
+   * descriptor had control over the private key corresponding to the ntor
+   * onion key, or null if the descriptor does not contain such a
+   * signature.
+   *
+   * @since 1.1.0
+   */
+  public String getNtorOnionKeyCrosscert();
+
+  /**
+   * Return the sign of the Ed25519 public key corresponding to the ntor
+   * onion key as 0 or 1, or -1 if the descriptor does not contain this
+   * information.
+   *
+   * @since 1.1.0
+   */
+  public int getNtorOnionKeyCrosscertSign();
+
+  /**
+   * Return whether the server accepts "tunneled" directory requests using
+   * a BEGIN_DIR cell over the server's OR port.
+   *
+   * @since 1.3.0
+   */
+  public boolean getTunnelledDirServer();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/TorperfResult.java b/src/main/java/org/torproject/descriptor/TorperfResult.java
new file mode 100644
index 0000000..188200b
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/TorperfResult.java
@@ -0,0 +1,215 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.util.List;
+import java.util.SortedMap;
+
+/**
+ * Contains performance measurement results from making simple HTTP
+ * requests over the Tor network.
+ *
+ * <p>The performance measurement service Torperf publishes performance
+ * data from making simple HTTP requests over the Tor network.  Torperf
+ * uses a trivial SOCKS client to download files of various sizes over the
+ * Tor network and notes how long substeps take.</p>
+ *
+ * @since 1.0.0
+ */
+public interface TorperfResult extends Descriptor {
+
+  /**
+   * Return all unrecognized keys together with their values, or null if
+   * all keys were recognized.
+   *
+   * @since 1.2.0
+   */
+  public SortedMap<String, String> getUnrecognizedKeys();
+
+  /**
+   * Return the configured name of the data source.
+   *
+   * @since 1.0.0
+   */
+  public String getSource();
+
+  /**
+   * Return the configured file size in bytes.
+   *
+   * @since 1.0.0
+   */
+  public int getFileSize();
+
+  /**
+   * Return the time in milliseconds since the epoch when the connection
+   * process started.
+   *
+   * @since 1.0.0
+   */
+  public long getStartMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the socket was
+   * created.
+   *
+   * @since 1.0.0
+   */
+  public long getSocketMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the socket was
+   * connected.
+   *
+   * @since 1.0.0
+   */
+  public long getConnectMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when SOCKS 5
+   * authentication methods have been negotiated.
+   *
+   * @since 1.0.0
+   */
+  public long getNegotiateMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the SOCKS
+   * request was sent.
+   *
+   * @since 1.0.0
+   */
+  public long getRequestMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the SOCKS
+   * response was received.
+   *
+   * @since 1.0.0
+   */
+  public long getResponseMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the HTTP
+   * request was written.
+   *
+   * @since 1.0.0
+   */
+  public long getDataRequestMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the first
+   * response was received.
+   *
+   * @since 1.0.0
+   */
+  public long getDataResponseMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the payload was
+   * complete.
+   *
+   * @since 1.0.0
+   */
+  public long getDataCompleteMillis();
+
+  /**
+   * Return the total number of bytes written.
+   *
+   * @since 1.0.0
+   */
+  public int getWriteBytes();
+
+  /**
+   * Return the total number of bytes read.
+   *
+   * @since 1.0.0
+   */
+  public int getReadBytes();
+
+  /**
+   * Return whether the request timed out (as opposed to failing), or
+   * null if the torperf line didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public Boolean didTimeout();
+
+  /**
+   * Return the times in milliseconds since the epoch when {@code x%} of
+   * expected bytes were read for {@code 0 <= x <= 100}, or null if the
+   * torperf line didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public SortedMap<Integer, Long> getDataPercentiles();
+
+  /**
+   * Return the time in milliseconds since the epoch when the circuit was
+   * launched, or -1 if the torperf line didn't contain that
+   * information.
+   *
+   * @since 1.0.0
+   */
+  public long getLaunchMillis();
+
+  /**
+   * Return the time in milliseconds since the epoch when the circuit was
+   * used, or -1 if the torperf line didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public long getUsedAtMillis();
+
+  /**
+   * Return a list of fingerprints of the relays in the circuit, or null
+   * if the torperf line didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public List<String> getPath();
+
+  /**
+   * Return a list of times in milliseconds since the epoch when circuit
+   * hops were built, or null if the torperf line didn't contain that
+   * information.
+   *
+   * @since 1.0.0
+   */
+  public List<Long> getBuildTimes();
+
+  /**
+   * Return the circuit build timeout that the Tor client used when
+   * building this circuit, or -1 if the torperf line didn't contain that
+   * information.
+   *
+   * @since 1.0.0
+   */
+  public long getTimeout();
+
+  /**
+   * Return the circuit build time quantile that the Tor client uses to
+   * determine its circuit-build timeout, or -1 if the torperf line
+   * didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public double getQuantile();
+
+  /**
+   * Return the identifier of the circuit used for this measurement, or
+   * -1 if the torperf line didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public int getCircId();
+
+  /**
+   * Return the identifier of the stream used for this measurement, or -1
+   * if the torperf line didn't contain that information.
+   *
+   * @since 1.0.0
+   */
+  public int getUsedBy();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BandwidthHistoryImpl.java b/src/main/java/org/torproject/descriptor/impl/BandwidthHistoryImpl.java
new file mode 100644
index 0000000..295e0a4
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BandwidthHistoryImpl.java
@@ -0,0 +1,100 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.descriptor.BandwidthHistory;
+
+public class BandwidthHistoryImpl implements BandwidthHistory {
+
+  protected BandwidthHistoryImpl(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    boolean isValid = false;
+    this.line = line;
+    if (partsNoOpt.length == 5 || partsNoOpt.length == 6) {
+      try {
+        this.historyEndMillis = ParseHelper.parseTimestampAtIndex(line,
+            partsNoOpt, 1, 2);
+        if (partsNoOpt[3].startsWith("(") &&
+            partsNoOpt[4].startsWith("s)")) {
+          this.intervalLength = Long.parseLong(partsNoOpt[3].
+              substring(1));
+          if (this.intervalLength <= 0L) {
+            throw new DescriptorParseException("Only positive interval "
+                + "lengths are allowed in line '" + line + "'.");
+          }
+          String[] values = null;
+          if (partsNoOpt.length == 5 &&
+              partsNoOpt[4].equals("s)")) {
+            /* There are no bandwidth values to parse. */
+            isValid = true;
+          } else if (partsNoOpt.length == 6) {
+            /* There are bandwidth values to parse. */
+            values = partsNoOpt[5].split(",", -1);
+          } else if (partsNoOpt[4].length() > 2) {
+            /* There are bandwidth values to parse, but there is no space
+             * between "s)" and "0,0,0,0".  Very old Tor versions around
+             * Tor 0.0.8 wrote such history lines, and even though
+             * dir-spec.txt implies a space here, the old format isn't
+             * totally broken.  Let's pretend there's a space. */
+            values = partsNoOpt[4].substring(2).split(",", -1);
+          }
+          if (values != null) {
+            this.bandwidthValues = new long[values.length];
+            for (int i = values.length - 1; i >= 0; i--) {
+              long bandwidthValue = Long.parseLong(values[i]);
+              if (bandwidthValue < 0L) {
+                throw new DescriptorParseException("Negative bandwidth "
+                    + "values are not allowed in line '" + line + "'.");
+              }
+              this.bandwidthValues[i] = bandwidthValue;
+            }
+            isValid = true;
+          }
+        }
+      } catch (NumberFormatException e) {
+        /* Handle below. */
+      }
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("Invalid bandwidth-history line "
+          + "'" + line + "'.");
+    }
+  }
+
+  private String line;
+  @Override
+  public String getLine() {
+    return this.line;
+  }
+
+  private long historyEndMillis;
+  @Override
+  public long getHistoryEndMillis() {
+    return this.historyEndMillis;
+  }
+
+  private long intervalLength;
+  @Override
+  public long getIntervalLength() {
+    return this.intervalLength;
+  }
+
+  private long[] bandwidthValues;
+  @Override
+  public SortedMap<Long, Long> getBandwidthValues() {
+    SortedMap<Long, Long> result = new TreeMap<>();
+    if (this.bandwidthValues != null) {
+      long endMillis = this.historyEndMillis;
+      for (int i = this.bandwidthValues.length - 1; i >= 0; i--) {
+        result.put(endMillis, bandwidthValues[i]);
+        endMillis -= this.intervalLength * 1000L;
+      }
+    }
+    return result;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BlockingIteratorImpl.java b/src/main/java/org/torproject/descriptor/impl/BlockingIteratorImpl.java
new file mode 100644
index 0000000..66426d8
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BlockingIteratorImpl.java
@@ -0,0 +1,98 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.NoSuchElementException;
+import java.util.Queue;
+
+/* Provide an iterator for a queue of objects and block when there are
+ * currently no objects in the queue.  Allow the producer to signal that
+ * there won't be further objects and unblock any waiting consumers. */
+public class BlockingIteratorImpl<T> implements Iterator<T> {
+
+  /* Queue containing produced elemnts waiting for consumers. */
+  private Queue<T> queue = new LinkedList<>();
+
+  /* Maximum number of elements in queue. */
+  private int maxQueueSize = 100;
+
+  /* Restrict object construction to the impl package. */
+  protected BlockingIteratorImpl() {
+  }
+
+  /* Create instance with maximum queue size. */
+  protected BlockingIteratorImpl(int maxQueueSize) {
+    this.maxQueueSize = maxQueueSize;
+  }
+
+  /* Add an object to the queue if there's still room. */
+  protected synchronized void add(T object) {
+    if (this.outOfDescriptors) {
+      throw new IllegalStateException("Internal error: Adding results to "
+          + "descriptor queue not allowed after sending end-of-stream "
+          + "object.");
+    }
+    while (this.queue.size() >= this.maxQueueSize) {
+      try {
+        wait();
+      } catch (InterruptedException e) {
+      }
+    }
+    this.queue.offer(object);
+    notifyAll();
+  }
+
+  /* Signalize that there won't be any further objects to be enqueued. */
+  private boolean outOfDescriptors = false;
+  protected synchronized void setOutOfDescriptors() {
+    if (this.outOfDescriptors) {
+      throw new IllegalStateException("Internal error: Sending "
+          + "end-of-stream object only permitted once.");
+    }
+    this.outOfDescriptors = true;
+    notifyAll();
+  }
+
+  /* Return whether there are more objects.  Block if there are currently
+   * no objects, but the producer hasn't signalized that there won't be
+   * further objects. */
+  @Override
+  public synchronized boolean hasNext() {
+    while (!this.outOfDescriptors && this.queue.isEmpty()) {
+      try {
+        wait();
+      } catch (InterruptedException e) {
+      }
+    }
+    return this.queue.peek() != null;
+  }
+
+  /* Return the next object in the queue or throw an exception when there
+   * are no further objects.  Block if there are currently no objects, but
+   * the producer hasn't signalized that there won't be further
+   * objects. */
+  @Override
+  public synchronized T next() {
+    while (!this.outOfDescriptors && this.queue.isEmpty()) {
+      try {
+        wait();
+      } catch (InterruptedException e) {
+      }
+    }
+    if (this.queue.peek() == null) {
+      throw new NoSuchElementException();
+    }
+    notifyAll();
+    return this.queue.remove();
+  }
+
+  /* Don't support explicitly removing objects.  They are removed
+   * anyway. */
+  @Override
+  public void remove() {
+    throw new UnsupportedOperationException();
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BridgeExtraInfoDescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/BridgeExtraInfoDescriptorImpl.java
new file mode 100644
index 0000000..15d40d8
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BridgeExtraInfoDescriptorImpl.java
@@ -0,0 +1,37 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.descriptor.BridgeExtraInfoDescriptor;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+
+public class BridgeExtraInfoDescriptorImpl
+    extends ExtraInfoDescriptorImpl implements BridgeExtraInfoDescriptor {
+
+  protected static List<ExtraInfoDescriptor> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<ExtraInfoDescriptor> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
+        "extra-info ");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      ExtraInfoDescriptor parsedDescriptor =
+          new BridgeExtraInfoDescriptorImpl(descriptorBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected BridgeExtraInfoDescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BridgeNetworkStatusImpl.java b/src/main/java/org/torproject/descriptor/impl/BridgeNetworkStatusImpl.java
new file mode 100644
index 0000000..bf3804d
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BridgeNetworkStatusImpl.java
@@ -0,0 +1,230 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.SortedMap;
+import java.util.TimeZone;
+
+import org.torproject.descriptor.BridgeNetworkStatus;
+
+/* Contains a bridge network status. */
+public class BridgeNetworkStatusImpl extends NetworkStatusImpl
+    implements BridgeNetworkStatus {
+
+  protected BridgeNetworkStatusImpl(byte[] statusBytes,
+      String fileName, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(statusBytes, failUnrecognizedDescriptorLines, false, false);
+    this.setPublishedMillisFromFileName(fileName);
+  }
+
+  private void setPublishedMillisFromFileName(String fileName)
+      throws DescriptorParseException {
+    if (this.publishedMillis != 0L) {
+      /* We already learned the publication timestamp from parsing the
+       * "published" line. */
+      return;
+    }
+    if (fileName.length() ==
+        "20000101-000000-4A0CCD2DDC7995083D73F5D667100C8A5831F16D".
+        length()) {
+      String publishedString = fileName.substring(0,
+          "yyyyMMdd-HHmmss".length());
+      try {
+        SimpleDateFormat fileNameFormat = new SimpleDateFormat(
+            "yyyyMMdd-HHmmss");
+        fileNameFormat.setLenient(false);
+        fileNameFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+        this.publishedMillis = fileNameFormat.parse(publishedString).
+            getTime();
+      } catch (ParseException e) {
+      }
+    }
+    if (this.publishedMillis == 0L) {
+      throw new DescriptorParseException("Unrecognized bridge network "
+          + "status file name '" + fileName + "'.");
+    }
+  }
+
+  protected void parseHeader(byte[] headerBytes)
+      throws DescriptorParseException {
+    /* Initialize flag-thresholds values here for the case that the status
+     * doesn't contain those values.  Initializing them in the constructor
+     * or when declaring variables wouldn't work, because those parts are
+     * evaluated later and would overwrite everything we parse here. */
+    this.stableUptime = -1L;
+    this.stableMtbf = -1L;
+    this.fastBandwidth = -1L;
+    this.guardWfu = -1.0;
+    this.guardTk = -1L;
+    this.guardBandwidthIncludingExits = -1L;
+    this.guardBandwidthExcludingExits = -1L;
+    this.enoughMtbfInfo = -1;
+    this.ignoringAdvertisedBws = -1;
+
+    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "published":
+        this.parsePublishedLine(line, parts);
+        break;
+      case "flag-thresholds":
+        this.parseFlagThresholdsLine(line, parts);
+        break;
+      default:
+        if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in bridge network status.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private void parsePublishedLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseFlagThresholdsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 2) {
+      throw new DescriptorParseException("No flag thresholds in line '"
+          + line + "'.");
+    }
+    SortedMap<String, String> flagThresholds =
+        ParseHelper.parseKeyValueStringPairs(line, parts, 1, "=");
+    try {
+      for (Map.Entry<String, String> e : flagThresholds.entrySet()) {
+        switch (e.getKey()) {
+        case "stable-uptime":
+          this.stableUptime = Long.parseLong(e.getValue());
+          break;
+        case "stable-mtbf":
+          this.stableMtbf = Long.parseLong(e.getValue());
+          break;
+        case "fast-speed":
+          this.fastBandwidth = Long.parseLong(e.getValue());
+          break;
+        case "guard-wfu":
+          this.guardWfu = Double.parseDouble(e.getValue().
+              replaceAll("%", ""));
+          break;
+        case "guard-tk":
+          this.guardTk = Long.parseLong(e.getValue());
+          break;
+        case "guard-bw-inc-exits":
+          this.guardBandwidthIncludingExits =
+              Long.parseLong(e.getValue());
+          break;
+        case "guard-bw-exc-exits":
+          this.guardBandwidthExcludingExits =
+              Long.parseLong(e.getValue());
+          break;
+        case "enough-mtbf":
+          this.enoughMtbfInfo = Integer.parseInt(e.getValue());
+          break;
+        case "ignoring-advertised-bws":
+          this.ignoringAdvertisedBws = Integer.parseInt(e.getValue());
+          break;
+        }
+      }
+    } catch (NumberFormatException ex) {
+      throw new DescriptorParseException("Illegal value in line '"
+          + line + "'.");
+    }
+  }
+
+  protected void parseDirSource(byte[] dirSourceBytes)
+      throws DescriptorParseException {
+    throw new DescriptorParseException("No directory source expected in "
+        + "bridge network status.");
+  }
+
+  protected void parseFooter(byte[] footerBytes)
+      throws DescriptorParseException {
+    throw new DescriptorParseException("No directory footer expected in "
+        + "bridge network status.");
+  }
+
+  protected void parseDirectorySignature(byte[] directorySignatureBytes)
+      throws DescriptorParseException {
+    throw new DescriptorParseException("No directory signature expected "
+        + "in bridge network status.");
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private long stableUptime;
+  @Override
+  public long getStableUptime() {
+    return this.stableUptime;
+  }
+
+  private long stableMtbf;
+  @Override
+  public long getStableMtbf() {
+    return this.stableMtbf;
+  }
+
+  private long fastBandwidth;
+  @Override
+  public long getFastBandwidth() {
+    return this.fastBandwidth;
+  }
+
+  private double guardWfu;
+  @Override
+  public double getGuardWfu() {
+    return this.guardWfu;
+  }
+
+  private long guardTk;
+  @Override
+  public long getGuardTk() {
+    return this.guardTk;
+  }
+
+  private long guardBandwidthIncludingExits;
+  @Override
+  public long getGuardBandwidthIncludingExits() {
+    return this.guardBandwidthIncludingExits;
+  }
+
+  private long guardBandwidthExcludingExits;
+  @Override
+  public long getGuardBandwidthExcludingExits() {
+    return this.guardBandwidthExcludingExits;
+  }
+
+  private int enoughMtbfInfo;
+  @Override
+  public int getEnoughMtbfInfo() {
+    return this.enoughMtbfInfo;
+  }
+
+  private int ignoringAdvertisedBws;
+  @Override
+  public int getIgnoringAdvertisedBws() {
+    return this.ignoringAdvertisedBws;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BridgePoolAssignmentImpl.java b/src/main/java/org/torproject/descriptor/impl/BridgePoolAssignmentImpl.java
new file mode 100644
index 0000000..99578e8
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BridgePoolAssignmentImpl.java
@@ -0,0 +1,99 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.descriptor.BridgePoolAssignment;
+
+/* TODO Write a test class. */
+public class BridgePoolAssignmentImpl extends DescriptorImpl
+    implements BridgePoolAssignment {
+
+  protected static List<BridgePoolAssignment> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<BridgePoolAssignment> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
+        "bridge-pool-assignment ");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      BridgePoolAssignment parsedDescriptor =
+          new BridgePoolAssignmentImpl(descriptorBytes,
+              failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected BridgePoolAssignmentImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.parseDescriptorBytes();
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList(
+        new String[] { "bridge-pool-assignment" }));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    this.checkFirstKeyword("bridge-pool-assignment");
+    this.clearParsedKeywords();
+    return;
+  }
+
+  private void parseDescriptorBytes() throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
+        useDelimiter("\n");
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.startsWith("bridge-pool-assignment ")) {
+        this.parseBridgePoolAssignmentLine(line);
+      } else {
+        this.parseBridgeLine(line);
+      }
+    }
+  }
+
+  private void parseBridgePoolAssignmentLine(String line)
+      throws DescriptorParseException {
+    String[] parts = line.split("[ \t]+");
+    if (parts.length != 3) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in bridge pool assignment.");
+    }
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
+        parts, 1, 2);
+  }
+
+  private void parseBridgeLine(String line)
+      throws DescriptorParseException {
+    String[] parts = line.split("[ \t]+");
+    if (parts.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in bridge pool assignment.");
+    }
+    String fingerprint = ParseHelper.parseTwentyByteHexString(line,
+        parts[0]);
+    String poolAndDetails = line.substring(line.indexOf(" ") + 1);
+    this.entries.put(fingerprint, poolAndDetails);
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private SortedMap<String, String> entries = new TreeMap<>();
+  @Override
+  public SortedMap<String, String> getEntries() {
+    return new TreeMap<>(this.entries);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BridgeServerDescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/BridgeServerDescriptorImpl.java
new file mode 100644
index 0000000..eb2b933
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BridgeServerDescriptorImpl.java
@@ -0,0 +1,37 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.descriptor.BridgeServerDescriptor;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ServerDescriptor;
+
+public class BridgeServerDescriptorImpl extends ServerDescriptorImpl
+    implements BridgeServerDescriptor {
+
+  protected static List<ServerDescriptor> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<ServerDescriptor> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
+        "router ");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      ServerDescriptor parsedDescriptor =
+          new BridgeServerDescriptorImpl(descriptorBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected BridgeServerDescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorCollectorImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorCollectorImpl.java
new file mode 100644
index 0000000..1a030ef
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorCollectorImpl.java
@@ -0,0 +1,249 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.Stack;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPInputStream;
+
+import org.torproject.descriptor.DescriptorCollector;
+
+public class DescriptorCollectorImpl implements DescriptorCollector {
+
+  @Override
+  public void collectDescriptors(String collecTorBaseUrl,
+      String[] remoteDirectories, long minLastModified,
+      File localDirectory, boolean deleteExtraneousLocalFiles) {
+    collecTorBaseUrl = collecTorBaseUrl.endsWith("/")
+        ? collecTorBaseUrl.substring(0, collecTorBaseUrl.length() - 1)
+        : collecTorBaseUrl;
+    if (minLastModified < 0) {
+      throw new IllegalArgumentException("A negative minimum "
+          + "last-modified time is not permitted.");
+    }
+    if (localDirectory.exists() && !localDirectory.isDirectory()) {
+      throw new IllegalArgumentException("Local directory already exists "
+          + "and is not a directory.");
+    }
+    SortedMap<String, Long> localFiles =
+        this.statLocalDirectory(localDirectory);
+    SortedMap<String, String> fetchedDirectoryListings =
+        this.fetchRemoteDirectories(collecTorBaseUrl, remoteDirectories);
+    SortedSet<String> parsedDirectories = new TreeSet<>();
+    SortedMap<String, Long> remoteFiles = new TreeMap<>();
+    for (Map.Entry<String, String> e :
+        fetchedDirectoryListings.entrySet()) {
+      String remoteDirectory = e.getKey();
+      String directoryListing = e.getValue();
+      SortedMap<String, Long> parsedRemoteFiles =
+          this.parseDirectoryListing(remoteDirectory, directoryListing);
+      if (parsedRemoteFiles == null) {
+        continue;
+      }
+      parsedDirectories.add(remoteDirectory);
+      remoteFiles.putAll(parsedRemoteFiles);
+    }
+    this.fetchRemoteFiles(collecTorBaseUrl, remoteFiles, minLastModified,
+        localDirectory, localFiles);
+    if (deleteExtraneousLocalFiles) {
+      this.deleteExtraneousLocalFiles(parsedDirectories, remoteFiles,
+          localDirectory, localFiles);
+    }
+  }
+
+  SortedMap<String, Long> statLocalDirectory(
+      File localDirectory) {
+    SortedMap<String, Long> localFiles = new TreeMap<>();
+    if (!localDirectory.exists()) {
+      return localFiles;
+    }
+    Stack<File> files = new Stack<>();
+    files.add(localDirectory);
+    while (!files.isEmpty()) {
+      File file = files.pop();
+      if (file.isDirectory()) {
+        files.addAll(Arrays.asList(file.listFiles()));
+      } else {
+        String localPath = file.getPath().substring(
+            localDirectory.getPath().length());
+        localFiles.put(localPath, file.lastModified());
+      }
+    }
+    return localFiles;
+  }
+
+  SortedMap<String, String> fetchRemoteDirectories(
+      String collecTorBaseUrl, String[] remoteDirectories) {
+    SortedMap<String, String> fetchedDirectoryListings = new TreeMap<>();
+    for (String remoteDirectory : remoteDirectories) {
+      String remoteDirectoryWithSlashAtBeginAndEnd =
+          (remoteDirectory.startsWith("/") ? "" : "/") + remoteDirectory
+          + (remoteDirectory.endsWith("/") ? "" : "/");
+      String directoryUrl = collecTorBaseUrl
+          + remoteDirectoryWithSlashAtBeginAndEnd;
+      String directoryListing = this.fetchRemoteDirectory(directoryUrl);
+      if (directoryListing.length() > 0) {
+        fetchedDirectoryListings.put(
+            remoteDirectoryWithSlashAtBeginAndEnd, directoryListing);
+      }
+    }
+    return fetchedDirectoryListings;
+  }
+
+  String fetchRemoteDirectory(String url) {
+    StringBuilder sb = new StringBuilder();
+    HttpURLConnection huc = null;
+    try {
+      URL u = new URL(url);
+      huc = (HttpURLConnection) u.openConnection();
+      huc.setRequestMethod("GET");
+      huc.connect();
+      int responseCode = huc.getResponseCode();
+      if (responseCode == 200) {
+        BufferedReader br = new BufferedReader(new InputStreamReader(
+            huc.getInputStream()));
+        String line;
+        while ((line = br.readLine()) != null) {
+          sb.append(line).append("\n");
+        }
+        br.close();
+      }
+    } catch (IOException e) {
+      e.printStackTrace();
+      if (huc != null) {
+        huc.disconnect();
+      }
+      return "";
+    }
+    return sb.toString();
+  }
+
+  final Pattern DIRECTORY_LISTING_LINE_PATTERN =
+      Pattern.compile(".* href=\"([^\"/]+)\"" /* filename */
+      + ".*>(\\d{2}-\\w{3}-\\d{4} \\d{2}:\\d{2})\\s*<.*"); /* dateTime */
+
+  SortedMap<String, Long> parseDirectoryListing(
+      String remoteDirectory, String directoryListing) {
+    SortedMap<String, Long> remoteFiles = new TreeMap<>();
+    DateFormat dateTimeFormat = ParseHelper.getDateFormat(
+        "dd-MMM-yyyy HH:mm");
+    try {
+      Scanner s = new Scanner(directoryListing);
+      s.useDelimiter("\n");
+      while (s.hasNext()) {
+        String line = s.next();
+        Matcher matcher = DIRECTORY_LISTING_LINE_PATTERN.matcher(line);
+        if (matcher.matches()) {
+          String filename = matcher.group(1);
+          long lastModifiedMillis = dateTimeFormat.parse(
+              matcher.group(2)).getTime();
+          remoteFiles.put(remoteDirectory + filename, lastModifiedMillis);
+        }
+      }
+      s.close();
+    } catch (ParseException e) {
+      e.printStackTrace();
+      return null;
+    }
+    return remoteFiles;
+  }
+
+  void fetchRemoteFiles(String collecTorBaseUrl,
+      SortedMap<String, Long> remoteFiles, long minLastModified,
+      File localDirectory, SortedMap<String, Long> localFiles) {
+    for (Map.Entry<String, Long> e : remoteFiles.entrySet()) {
+      String filename = e.getKey();
+      long lastModifiedMillis = e.getValue();
+      if (lastModifiedMillis < minLastModified ||
+          (localFiles.containsKey(filename) &&
+          localFiles.get(filename) >= lastModifiedMillis)) {
+        continue;
+      }
+      String url = collecTorBaseUrl + filename;
+      File destinationFile = new File(localDirectory.getPath()
+          + filename);
+      this.fetchRemoteFile(url, destinationFile, lastModifiedMillis);
+    }
+  }
+
+  void fetchRemoteFile(String url, File destinationFile,
+      long lastModifiedMillis) {
+    HttpURLConnection huc = null;
+    try {
+      File destinationDirectory = destinationFile.getParentFile();
+      destinationDirectory.mkdirs();
+      File tempDestinationFile = new File(destinationDirectory, "."
+          + destinationFile.getName());
+      BufferedOutputStream bos = new BufferedOutputStream(
+          new FileOutputStream(tempDestinationFile));
+      URL u = new URL(url);
+      huc = (HttpURLConnection) u.openConnection();
+      huc.setRequestMethod("GET");
+      if (!url.endsWith(".xz")) {
+        huc.addRequestProperty("Accept-Encoding", "gzip");
+      }
+      huc.connect();
+      int responseCode = huc.getResponseCode();
+      if (responseCode == 200) {
+        InputStream is;
+        if (huc.getContentEncoding() != null &&
+            huc.getContentEncoding().equalsIgnoreCase("gzip")) {
+          is = new GZIPInputStream(huc.getInputStream());
+        } else {
+          is = huc.getInputStream();
+        }
+        BufferedInputStream bis = new BufferedInputStream(is);
+        int len;
+        byte[] data = new byte[8192];
+        while ((len = bis.read(data, 0, 8192)) >= 0) {
+          bos.write(data, 0, len);
+        }
+        bis.close();
+        bos.close();
+        tempDestinationFile.renameTo(destinationFile);
+        destinationFile.setLastModified(lastModifiedMillis);
+      }
+    } catch (IOException e) {
+      e.printStackTrace();
+      if (huc != null) {
+        huc.disconnect();
+      }
+    }
+  }
+
+  void deleteExtraneousLocalFiles(
+      SortedSet<String> parsedDirectories,
+      SortedMap<String, Long> remoteFiles, File localDirectory,
+      SortedMap<String, Long> localFiles) {
+    for (String localPath : localFiles.keySet()) {
+      for (String remoteDirectory : parsedDirectories) {
+        if (localPath.startsWith(remoteDirectory)) {
+          if (!remoteFiles.containsKey(localPath)) {
+            new File(localDirectory.getPath() + localPath).delete();
+          }
+        }
+      }
+    }
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorDownloaderImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorDownloaderImpl.java
new file mode 100644
index 0000000..e726ce9
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorDownloaderImpl.java
@@ -0,0 +1,283 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.descriptor.DescriptorRequest;
+import org.torproject.descriptor.DescriptorDownloader;
+
+public class DescriptorDownloaderImpl
+    implements DescriptorDownloader {
+
+  private boolean hasStartedDownloading = false;
+
+  private SortedMap<String, DirectoryDownloader> directoryAuthorities =
+      new TreeMap<>();
+  @Override
+  public void addDirectoryAuthority(String nickname, String ip,
+      int dirPort) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.checkDirectoryParameters(nickname, ip, dirPort);
+    DirectoryDownloader directoryAuthority = new DirectoryDownloader(
+        nickname, ip, dirPort);
+    this.directoryAuthorities.put(nickname, directoryAuthority);
+  }
+
+  private SortedMap<String, DirectoryDownloader> directoryMirrors =
+      new TreeMap<>();
+  @Override
+  public void addDirectoryMirror(String nickname, String ip,
+      int dirPort) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.checkDirectoryParameters(nickname, ip, dirPort);
+    DirectoryDownloader directoryMirror = new DirectoryDownloader(
+        nickname, ip, dirPort);
+    this.directoryMirrors.put(nickname, directoryMirror);
+    /* TODO Implement prioritizing mirrors for non-vote downloads. */
+    throw new UnsupportedOperationException("Prioritizing directory "
+        + "mirrors over directory authorities is not implemented yet.  "
+        + "Until it is, configuring directory mirrors is misleading and "
+        + "therefore not supported.");
+  }
+
+  private void checkDirectoryParameters(String nickname, String ip,
+      int dirPort) {
+    if (nickname == null || nickname.length() < 1) {
+      throw new IllegalArgumentException("'" + nickname + "' is not a "
+          + "valid nickname.");
+    }
+    if (ip == null || ip.length() < 7 || ip.split("\\.").length != 4) {
+      throw new IllegalArgumentException("'" + ip + "' is not a valid IP "
+          + "address.");
+    }
+    if (dirPort < 1 || dirPort > 65535) {
+      throw new IllegalArgumentException(String.valueOf(dirPort) + " is "
+          + "not a valid DirPort.");
+    }
+    /* TODO Relax the requirement for directory nicknames to be unique.
+     * In theory, we can identify them by ip+port. */
+    if (this.directoryAuthorities.containsKey(nickname) ||
+        this.directoryMirrors.containsKey(nickname)) {
+      throw new IllegalArgumentException("Directory nicknames must be "
+          + "unique.");
+    }
+  }
+
+  private boolean downloadConsensus = false;
+  @Override
+  public void setIncludeCurrentConsensus() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.downloadConsensus = true;
+  }
+
+  private boolean downloadConsensusFromAllAuthorities = false;
+  @Override
+  public void setIncludeCurrentConsensusFromAllDirectoryAuthorities() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.downloadConsensusFromAllAuthorities = true;
+  }
+
+  private boolean includeCurrentReferencedVotes = false;
+  @Override
+  public void setIncludeCurrentReferencedVotes() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.includeCurrentReferencedVotes = true;
+  }
+
+  private Set<String> downloadVotes = new HashSet<>();
+  @Override
+  public void setIncludeCurrentVote(String fingerprint) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.checkVoteFingerprint(fingerprint);
+    this.downloadVotes.add(fingerprint);
+  }
+
+  @Override
+  public void setIncludeCurrentVotes(Set<String> fingerprints) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    if (fingerprints == null) {
+      throw new IllegalArgumentException("Set of fingerprints must not "
+          + "be null.");
+    }
+    for (String fingerprint : fingerprints) {
+      this.checkVoteFingerprint(fingerprint);
+    }
+    for (String fingerprint : fingerprints) {
+      this.setIncludeCurrentVote(fingerprint);
+    }
+  }
+
+  private void checkVoteFingerprint(String fingerprint) {
+    if (fingerprint == null || fingerprint.length() != 40) {
+      throw new IllegalArgumentException("'" + fingerprint + "' is not a "
+          + "valid fingerprint.");
+    }
+  }
+
+  @Override
+  public void setIncludeReferencedServerDescriptors() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    /* TODO Implement me. */
+    throw new UnsupportedOperationException("Downloading server "
+        + "descriptors is not implemented yet.");
+  }
+
+  @Override
+  public void setExcludeServerDescriptor(String identifier) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    /* TODO Implement me. */
+    throw new UnsupportedOperationException("Downloading server "
+        + "descriptors is not implemented yet.");
+  }
+
+  @Override
+  public void setExcludeServerDescriptors(Set<String> identifier) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    /* TODO Implement me. */
+    throw new UnsupportedOperationException("Downloading server "
+        + "descriptors is not implemented yet.");
+  }
+
+  @Override
+  public void setIncludeReferencedExtraInfoDescriptors() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    /* TODO Implement me. */
+    throw new UnsupportedOperationException("Downloading extra-info "
+        + "descriptors is not implemented yet.");
+  }
+
+  @Override
+  public void setExcludeExtraInfoDescriptor(String identifier) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    /* TODO Implement me. */
+    throw new UnsupportedOperationException("Downloading extra-info "
+        + "descriptors is not implemented yet.");
+  }
+
+  @Override
+  public void setExcludeExtraInfoDescriptors(Set<String> identifiers) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    /* TODO Implement me. */
+    throw new UnsupportedOperationException("Downloading extra-info "
+        + "descriptors is not implemented yet.");
+  }
+
+  private long readTimeoutMillis = 60L * 1000L;
+  @Override
+  public void setReadTimeout(long readTimeoutMillis) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    if (readTimeoutMillis < 0L) {
+      throw new IllegalArgumentException("Read timeout value "
+          + String.valueOf(readTimeoutMillis) + " may not be "
+          + "negative.");
+    }
+    this.readTimeoutMillis = readTimeoutMillis;
+  }
+
+  private long connectTimeoutMillis = 60L * 1000L;
+  @Override
+  public void setConnectTimeout(long connectTimeoutMillis) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    if (connectTimeoutMillis < 0L) {
+      throw new IllegalArgumentException("Connect timeout value "
+          + String.valueOf(connectTimeoutMillis) + " may not be "
+          + "negative.");
+    }
+    this.connectTimeoutMillis = connectTimeoutMillis;
+  }
+
+  private long globalTimeoutMillis = 60L * 60L * 1000L;
+  @Override
+  public void setGlobalTimeout(long globalTimeoutMillis) {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    if (globalTimeoutMillis < 0L) {
+      throw new IllegalArgumentException("Global timeout value "
+          + String.valueOf(globalTimeoutMillis) + " may not be "
+          + "negative.");
+    }
+    this.globalTimeoutMillis = globalTimeoutMillis;
+  }
+
+  private boolean failUnrecognizedDescriptorLines = false;
+  @Override
+  public void setFailUnrecognizedDescriptorLines() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to download.");
+    }
+    this.failUnrecognizedDescriptorLines = true;
+  }
+
+  @Override
+  public Iterator<DescriptorRequest> downloadDescriptors() {
+    if (this.hasStartedDownloading) {
+      throw new IllegalStateException("Initiating downloads is only "
+          + "permitted once.");
+    }
+    this.hasStartedDownloading = true;
+    DownloadCoordinatorImpl downloadCoordinator =
+        new DownloadCoordinatorImpl(this.directoryAuthorities,
+        this.directoryMirrors, this.downloadConsensus,
+        this.downloadConsensusFromAllAuthorities, this.downloadVotes,
+        this.includeCurrentReferencedVotes, this.connectTimeoutMillis,
+        this.readTimeoutMillis, this.globalTimeoutMillis,
+        this.failUnrecognizedDescriptorLines);
+    Iterator<DescriptorRequest> descriptorQueue = downloadCoordinator.
+        getDescriptorQueue();
+    return descriptorQueue;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorFileImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorFileImpl.java
new file mode 100644
index 0000000..801c546
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorFileImpl.java
@@ -0,0 +1,78 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorFile;
+
+public class DescriptorFileImpl implements DescriptorFile {
+
+  private File directory;
+  protected void setDirectory(File directory) {
+    this.directory = directory;
+  }
+  @Override
+  public File getDirectory() {
+    return this.directory;
+  }
+
+  private File tarball;
+  protected void setTarball(File tarball) {
+    this.tarball = tarball;
+  }
+  @Override
+  public File getTarball() {
+    return this.tarball;
+  }
+
+  private File file;
+  protected void setFile(File file) {
+    this.file = file;
+  }
+  @Override
+  public File getFile() {
+    return this.file;
+  }
+
+  private String fileName;
+  protected void setFileName(String fileName) {
+    this.fileName = fileName;
+  }
+  @Override
+  public String getFileName() {
+    return this.fileName;
+  }
+
+  private long lastModified;
+  protected void setLastModified(long lastModified) {
+    this.lastModified = lastModified;
+  }
+  @Override
+  public long getLastModified() {
+    return this.lastModified;
+  }
+
+  private List<Descriptor> descriptors;
+  protected void setDescriptors(List<Descriptor> descriptors) {
+    this.descriptors = descriptors;
+  }
+  @Override
+  public List<Descriptor> getDescriptors() {
+    return this.descriptors == null ? new ArrayList<Descriptor>() :
+      new ArrayList<>(this.descriptors);
+  }
+
+  private Exception exception;
+  protected void setException(Exception exception) {
+    this.exception = exception;
+  }
+  @Override
+  public Exception getException() {
+    return this.exception;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorImpl.java
new file mode 100644
index 0000000..5625b3f
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorImpl.java
@@ -0,0 +1,337 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+
+import org.torproject.descriptor.Descriptor;
+
+public abstract class DescriptorImpl implements Descriptor {
+
+  protected static List<Descriptor> parseDescriptors(
+      byte[] rawDescriptorBytes, String fileName,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<Descriptor> parsedDescriptors = new ArrayList<>();
+    if (rawDescriptorBytes == null) {
+      return parsedDescriptors;
+    }
+    byte[] first100Chars = new byte[Math.min(100,
+        rawDescriptorBytes.length)];
+    System.arraycopy(rawDescriptorBytes, 0, first100Chars, 0,
+        first100Chars.length);
+    String firstLines = new String(first100Chars);
+    if (firstLines.startsWith("@type network-status-consensus-3 1.") ||
+        firstLines.startsWith("@type network-status-microdesc-"
+            + "consensus-3 1.") ||
+        ((firstLines.startsWith("network-status-version 3") ||
+        firstLines.contains("\nnetwork-status-version 3")) &&
+        firstLines.contains("\nvote-status consensus\n"))) {
+      parsedDescriptors.addAll(RelayNetworkStatusConsensusImpl.
+          parseConsensuses(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type network-status-vote-3 1.")
+        || ((firstLines.startsWith("network-status-version 3\n") ||
+        firstLines.contains("\nnetwork-status-version 3\n")) &&
+        firstLines.contains("\nvote-status vote\n"))) {
+      parsedDescriptors.addAll(RelayNetworkStatusVoteImpl.
+          parseVotes(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type bridge-network-status 1.")
+        || firstLines.startsWith("r ")) {
+      parsedDescriptors.add(new BridgeNetworkStatusImpl(
+          rawDescriptorBytes, fileName, failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith(
+        "@type bridge-server-descriptor 1.")) {
+      parsedDescriptors.addAll(BridgeServerDescriptorImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type server-descriptor 1.") ||
+        firstLines.startsWith("router ") ||
+        firstLines.contains("\nrouter ")) {
+      parsedDescriptors.addAll(RelayServerDescriptorImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type bridge-extra-info 1.")) {
+      parsedDescriptors.addAll(BridgeExtraInfoDescriptorImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type extra-info 1.") ||
+        firstLines.startsWith("extra-info ") ||
+        firstLines.contains("\nextra-info ")) {
+      parsedDescriptors.addAll(RelayExtraInfoDescriptorImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type microdescriptor 1.") ||
+        firstLines.startsWith("onion-key\n") ||
+        firstLines.contains("\nonion-key\n")) {
+      parsedDescriptors.addAll(MicrodescriptorImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type bridge-pool-assignment 1.") ||
+        firstLines.startsWith("bridge-pool-assignment ") ||
+        firstLines.contains("\nbridge-pool-assignment ")) {
+      parsedDescriptors.addAll(BridgePoolAssignmentImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type dir-key-certificate-3 1.") ||
+        firstLines.startsWith("dir-key-certificate-version ") ||
+        firstLines.contains("\ndir-key-certificate-version ")) {
+      parsedDescriptors.addAll(DirectoryKeyCertificateImpl.
+          parseDescriptors(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type tordnsel 1.") ||
+        firstLines.startsWith("ExitNode ") ||
+        firstLines.contains("\nExitNode ")) {
+      parsedDescriptors.add(new ExitListImpl(rawDescriptorBytes, fileName,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type network-status-2 1.") ||
+        firstLines.startsWith("network-status-version 2\n") ||
+        firstLines.contains("\nnetwork-status-version 2\n")) {
+      parsedDescriptors.add(new RelayNetworkStatusImpl(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type directory 1.") ||
+        firstLines.startsWith("signed-directory\n") ||
+        firstLines.contains("\nsigned-directory\n")) {
+      parsedDescriptors.add(new RelayDirectoryImpl(rawDescriptorBytes,
+          failUnrecognizedDescriptorLines));
+    } else if (firstLines.startsWith("@type torperf 1.")) {
+      parsedDescriptors.addAll(TorperfResultImpl.parseTorperfResults(
+          rawDescriptorBytes, failUnrecognizedDescriptorLines));
+    } else {
+      throw new DescriptorParseException("Could not detect descriptor "
+          + "type in descriptor starting with '" + firstLines + "'.");
+    }
+    return parsedDescriptors;
+  }
+
+  protected static List<byte[]> splitRawDescriptorBytes(
+      byte[] rawDescriptorBytes, String startToken) {
+    List<byte[]> rawDescriptors = new ArrayList<>();
+    String splitToken = "\n" + startToken;
+    String ascii;
+    try {
+      ascii = new String(rawDescriptorBytes, "US-ASCII");
+    } catch (UnsupportedEncodingException e) {
+      return rawDescriptors;
+    }
+    int endAllDescriptors = rawDescriptorBytes.length,
+        startAnnotations = 0;
+    boolean containsAnnotations = ascii.startsWith("@") ||
+        ascii.contains("\n@");
+    while (startAnnotations < endAllDescriptors) {
+      int startDescriptor;
+      if (ascii.indexOf(startToken, startAnnotations) == 0) {
+        startDescriptor = startAnnotations;
+      } else {
+        startDescriptor = ascii.indexOf(splitToken, startAnnotations - 1);
+        if (startDescriptor < 0) {
+          break;
+        } else {
+          startDescriptor += 1;
+        }
+      }
+      int endDescriptor = -1;
+      if (containsAnnotations) {
+        endDescriptor = ascii.indexOf("\n@", startDescriptor);
+      }
+      if (endDescriptor < 0) {
+        endDescriptor = ascii.indexOf(splitToken, startDescriptor);
+      }
+      if (endDescriptor < 0) {
+        endDescriptor = endAllDescriptors - 1;
+      }
+      endDescriptor += 1;
+      byte[] rawDescriptor = new byte[endDescriptor - startAnnotations];
+      System.arraycopy(rawDescriptorBytes, startAnnotations,
+          rawDescriptor, 0, endDescriptor - startAnnotations);
+      startAnnotations = endDescriptor;
+      rawDescriptors.add(rawDescriptor);
+    }
+    return rawDescriptors;
+  }
+
+  protected byte[] rawDescriptorBytes;
+  @Override
+  public byte[] getRawDescriptorBytes() {
+    return this.rawDescriptorBytes;
+  }
+
+  protected boolean failUnrecognizedDescriptorLines = false;
+
+  protected List<String> unrecognizedLines;
+  @Override
+  public List<String> getUnrecognizedLines() {
+    return this.unrecognizedLines == null ? new ArrayList<String>() :
+        new ArrayList<>(this.unrecognizedLines);
+  }
+
+  protected DescriptorImpl(byte[] rawDescriptorBytes,
+      boolean failUnrecognizedDescriptorLines, boolean blankLinesAllowed)
+      throws DescriptorParseException {
+    this.rawDescriptorBytes = rawDescriptorBytes;
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+    this.cutOffAnnotations(rawDescriptorBytes);
+    this.countKeywords(rawDescriptorBytes, blankLinesAllowed);
+  }
+
+  /* Parse annotation lines from the descriptor bytes. */
+  private List<String> annotations = new ArrayList<>();
+  private void cutOffAnnotations(byte[] rawDescriptorBytes)
+      throws DescriptorParseException {
+    String ascii = new String(rawDescriptorBytes);
+    int start = 0;
+    while ((start == 0 && ascii.startsWith("@")) ||
+        (start > 0 && ascii.indexOf("\n@", start - 1) >= 0)) {
+      int end = ascii.indexOf("\n", start);
+      if (end < 0) {
+        throw new DescriptorParseException("Annotation line does not "
+            + "contain a newline.");
+      }
+      this.annotations.add(ascii.substring(start, end));
+      start = end + 1;
+    }
+    if (start > 0) {
+      int length = rawDescriptorBytes.length;
+      byte[] rawDescriptor = new byte[length - start];
+      System.arraycopy(rawDescriptorBytes, start, rawDescriptor, 0,
+        length - start);
+      this.rawDescriptorBytes = rawDescriptor;
+    }
+  }
+  @Override
+  public List<String> getAnnotations() {
+    return new ArrayList<>(this.annotations);
+  }
+
+  /* Count parsed keywords for consistency checks by subclasses. */
+  private String firstKeyword, lastKeyword;
+  private Map<String, Integer> parsedKeywords = new HashMap<>();
+  private void countKeywords(byte[] rawDescriptorBytes,
+      boolean blankLinesAllowed) throws DescriptorParseException {
+    if (rawDescriptorBytes.length == 0) {
+      throw new DescriptorParseException("Descriptor is empty.");
+    }
+    String descriptorString = new String(rawDescriptorBytes);
+    if (!blankLinesAllowed && (descriptorString.startsWith("\n") ||
+        descriptorString.contains("\n\n"))) {
+      throw new DescriptorParseException("Blank lines are not allowed.");
+    }
+    boolean skipCrypto = false;
+    Scanner s = new Scanner(descriptorString).useDelimiter("\n");
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.startsWith("-----BEGIN")) {
+        skipCrypto = true;
+      } else if (line.startsWith("-----END")) {
+        skipCrypto = false;
+      } else if (!line.isEmpty() && !line.startsWith("@") &&
+          !skipCrypto) {
+        String lineNoOpt = line.startsWith("opt ") ?
+            line.substring("opt ".length()) : line;
+        String keyword = lineNoOpt.split(" ", -1)[0];
+        if (keyword.equals("")) {
+          throw new DescriptorParseException("Illegal keyword in line '"
+              + line + "'.");
+        }
+        if (this.firstKeyword == null) {
+          this.firstKeyword = keyword;
+        }
+        lastKeyword = keyword;
+        if (parsedKeywords.containsKey(keyword)) {
+          parsedKeywords.put(keyword, parsedKeywords.get(keyword) + 1);
+        } else {
+          parsedKeywords.put(keyword, 1);
+        }
+      }
+    }
+  }
+
+  protected void checkFirstKeyword(String keyword)
+      throws DescriptorParseException {
+    if (this.firstKeyword == null ||
+        !this.firstKeyword.equals(keyword)) {
+      throw new DescriptorParseException("Keyword '" + keyword + "' must "
+          + "be contained in the first line.");
+    }
+  }
+
+  protected void checkLastKeyword(String keyword)
+      throws DescriptorParseException {
+    if (this.lastKeyword == null ||
+        !this.lastKeyword.equals(keyword)) {
+      throw new DescriptorParseException("Keyword '" + keyword + "' must "
+          + "be contained in the last line.");
+    }
+  }
+
+  protected void checkExactlyOnceKeywords(Set<String> keywords)
+      throws DescriptorParseException {
+    for (String keyword : keywords) {
+      int contained = 0;
+      if (this.parsedKeywords.containsKey(keyword)) {
+        contained = this.parsedKeywords.get(keyword);
+      }
+      if (contained != 1) {
+        throw new DescriptorParseException("Keyword '" + keyword + "' is "
+            + "contained " + contained + " times, but must be contained "
+            + "exactly once.");
+      }
+    }
+  }
+
+  protected void checkAtLeastOnceKeywords(Set<String> keywords)
+      throws DescriptorParseException {
+    for (String keyword : keywords) {
+      if (!this.parsedKeywords.containsKey(keyword)) {
+        throw new DescriptorParseException("Keyword '" + keyword + "' is "
+            + "contained 0 times, but must be contained at least once.");
+      }
+    }
+  }
+
+  protected void checkAtMostOnceKeywords(Set<String> keywords)
+      throws DescriptorParseException {
+    for (String keyword : keywords) {
+      if (this.parsedKeywords.containsKey(keyword) &&
+          this.parsedKeywords.get(keyword) > 1) {
+        throw new DescriptorParseException("Keyword '" + keyword + "' is "
+            + "contained " + this.parsedKeywords.get(keyword) + " times, "
+            + "but must be contained at most once.");
+      }
+    }
+  }
+
+  protected void checkKeywordsDependOn(Set<String> dependentKeywords,
+      String dependingKeyword) throws DescriptorParseException {
+    for (String dependentKeyword : dependentKeywords) {
+      if (this.parsedKeywords.containsKey(dependentKeyword) &&
+          !this.parsedKeywords.containsKey(dependingKeyword)) {
+        throw new DescriptorParseException("Keyword '" + dependentKeyword
+            + "' is contained, but keyword '" + dependingKeyword + "' is "
+            + "not.");
+      }
+    }
+  }
+
+  protected int getKeywordCount(String keyword) {
+    if (!this.parsedKeywords.containsKey(keyword)) {
+      return 0;
+    } else {
+      return this.parsedKeywords.get(keyword);
+    }
+  }
+
+  protected void clearParsedKeywords() {
+    this.parsedKeywords = null;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorParseException.java b/src/main/java/org/torproject/descriptor/impl/DescriptorParseException.java
new file mode 100644
index 0000000..0f9add2
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorParseException.java
@@ -0,0 +1,15 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+/**
+ * @deprecated Replaced by
+ * org.torproject.descriptor.DescriptorParseException
+ */
+@Deprecated public class DescriptorParseException extends Exception {
+  private static final long serialVersionUID = 100L;
+  protected DescriptorParseException(String message) {
+    super(message);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
new file mode 100644
index 0000000..6ac53f8
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
@@ -0,0 +1,28 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.List;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorParser;
+
+public class DescriptorParserImpl implements DescriptorParser {
+
+  private boolean failUnrecognizedDescriptorLines;
+
+  @Override
+  public void setFailUnrecognizedDescriptorLines(
+      boolean failUnrecognizedDescriptorLines) {
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+  }
+
+  @Override
+  public List<Descriptor> parseDescriptors(byte[] rawDescriptorBytes,
+      String fileName) throws DescriptorParseException {
+    return DescriptorImpl.parseDescriptors(rawDescriptorBytes, fileName,
+        this.failUnrecognizedDescriptorLines);
+  }
+}
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java
new file mode 100644
index 0000000..8da88e9
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java
@@ -0,0 +1,364 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.Stack;
+import java.util.TreeMap;
+
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
+import org.apache.commons.compress.compressors.xz.XZCompressorInputStream;
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorFile;
+import org.torproject.descriptor.DescriptorParser;
+import org.torproject.descriptor.DescriptorReader;
+
+public class DescriptorReaderImpl implements DescriptorReader {
+
+  private boolean hasStartedReading = false;
+
+  private List<File> directories = new ArrayList<>();
+  @Override
+  public void addDirectory(File directory) {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to read.");
+    }
+    this.directories.add(directory);
+  }
+
+  private List<File> tarballs = new ArrayList<>();
+  @Override
+  public void addTarball(File tarball) {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to read.");
+    }
+    this.tarballs.add(tarball);
+  }
+
+  private File historyFile;
+  @Override
+  public void setExcludeFiles(File historyFile) {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to read.");
+    }
+    this.historyFile = historyFile;
+  }
+
+  private SortedMap<String, Long> excludedFiles;
+  @Override
+  public void setExcludedFiles(SortedMap<String, Long> excludedFiles) {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to read.");
+    }
+    this.excludedFiles = excludedFiles;
+  }
+
+  @Override
+  public SortedMap<String, Long> getExcludedFiles() {
+    if (this.reader == null || !this.reader.hasFinishedReading) {
+      throw new IllegalStateException("Operation is not permitted before "
+          + "finishing to read.");
+    }
+    return new TreeMap<>(this.reader.excludedFilesAfter);
+  }
+
+  @Override
+  public SortedMap<String, Long> getParsedFiles() {
+    if (this.reader == null || !this.reader.hasFinishedReading) {
+      throw new IllegalStateException("Operation is not permitted before "
+          + "finishing to read.");
+    }
+    return new TreeMap<>(this.reader.parsedFilesAfter);
+  }
+
+  private boolean failUnrecognizedDescriptorLines = false;
+  @Override
+  public void setFailUnrecognizedDescriptorLines() {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to read.");
+    }
+    this.failUnrecognizedDescriptorLines = true;
+  }
+
+  private Integer maxDescriptorFilesInQueue = null;
+  @Override
+  public void setMaxDescriptorFilesInQueue(int max) {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Reconfiguration is not permitted "
+          + "after starting to read.");
+    }
+    this.maxDescriptorFilesInQueue = max;
+  }
+
+  private DescriptorReaderRunnable reader;
+  @Override
+  public Iterator<DescriptorFile> readDescriptors() {
+    if (this.hasStartedReading) {
+      throw new IllegalStateException("Initiating reading is only "
+          + "permitted once.");
+    }
+    this.hasStartedReading = true;
+    BlockingIteratorImpl<DescriptorFile> descriptorQueue =
+        this.maxDescriptorFilesInQueue == null
+        ? new BlockingIteratorImpl<DescriptorFile>()
+        : new BlockingIteratorImpl<DescriptorFile>(
+        this.maxDescriptorFilesInQueue);
+    this.reader = new DescriptorReaderRunnable(this.directories,
+        this.tarballs, descriptorQueue, this.historyFile,
+        this.excludedFiles, this.failUnrecognizedDescriptorLines);
+    new Thread(this.reader).start();
+    return descriptorQueue;
+  }
+
+  private static class DescriptorReaderRunnable implements Runnable {
+    private List<File> directories;
+    private List<File> tarballs;
+    private BlockingIteratorImpl<DescriptorFile> descriptorQueue;
+    private File historyFile;
+    private SortedMap<String, Long> excludedFilesBefore = new TreeMap<>(),
+        excludedFilesAfter = new TreeMap<>(),
+        parsedFilesAfter = new TreeMap<>();
+    private DescriptorParser descriptorParser;
+    private boolean hasFinishedReading = false;
+    private DescriptorReaderRunnable(List<File> directories,
+        List<File> tarballs,
+        BlockingIteratorImpl<DescriptorFile> descriptorQueue,
+        File historyFile, SortedMap<String, Long> excludedFiles,
+        boolean failUnrecognizedDescriptorLines) {
+      this.directories = directories;
+      this.tarballs = tarballs;
+      this.descriptorQueue = descriptorQueue;
+      this.historyFile = historyFile;
+      if (excludedFiles != null) {
+        this.excludedFilesBefore = excludedFiles;
+      }
+      this.descriptorParser = new DescriptorParserImpl();
+      this.descriptorParser.setFailUnrecognizedDescriptorLines(
+          failUnrecognizedDescriptorLines);
+    }
+    public void run() {
+      try {
+        this.readOldHistory();
+        this.readDescriptors();
+        this.readTarballs();
+        this.hasFinishedReading = true;
+      } catch (Throwable t) {
+        /* We're usually not writing to stdout or stderr, but we shouldn't
+         * stay quiet about this potential bug.  If we were to switch to a
+         * logging API, this would qualify as ERROR. */
+        System.err.println("Bug: uncaught exception or error while "
+            + "reading descriptors:");
+        t.printStackTrace();
+      } finally {
+        this.descriptorQueue.setOutOfDescriptors();
+      }
+      if (this.hasFinishedReading) {
+        this.writeNewHistory();
+      }
+    }
+    private void readOldHistory() {
+      if (this.historyFile == null) {
+        return;
+      }
+      try {
+        BufferedReader br = new BufferedReader(new FileReader(
+            this.historyFile));
+        String line;
+        while ((line = br.readLine()) != null) {
+          if (!line.contains(" ")) {
+            /* TODO Handle this problem? */
+            continue;
+          }
+          long lastModifiedMillis = Long.parseLong(line.substring(0,
+              line.indexOf(" ")));
+          String absolutePath = line.substring(line.indexOf(" ") + 1);
+          this.excludedFilesBefore.put(absolutePath, lastModifiedMillis);
+        }
+        br.close();
+      } catch (IOException e) {
+        /* TODO Handle this exception. */
+      } catch (NumberFormatException e) {
+        /* TODO Handle this exception. */
+      }
+    }
+    private void writeNewHistory() {
+      if (this.historyFile == null) {
+        return;
+      }
+      try {
+        if (this.historyFile.getParentFile() != null) {
+          this.historyFile.getParentFile().mkdirs();
+        }
+        BufferedWriter bw = new BufferedWriter(new FileWriter(
+            this.historyFile));
+        SortedMap<String, Long> newHistory = new TreeMap<>();
+        newHistory.putAll(this.excludedFilesAfter);
+        newHistory.putAll(this.parsedFilesAfter);
+        for (Map.Entry<String, Long> e : newHistory.entrySet()) {
+          String absolutePath = e.getKey();
+          long lastModifiedMillis = e.getValue();
+          bw.write(String.valueOf(lastModifiedMillis) + " " + absolutePath
+              + "\n");
+        }
+        bw.close();
+      } catch (IOException e) {
+        /* TODO Handle this exception. */
+      }
+    }
+    private void readDescriptors() {
+      for (File directory : this.directories) {
+        if (!directory.exists() || !directory.isDirectory()) {
+          continue;
+        }
+        Stack<File> files = new Stack<>();
+        files.add(directory);
+        boolean abortReading = false;
+        while (!abortReading && !files.isEmpty()) {
+          File file = files.pop();
+          if (file.isDirectory()) {
+            files.addAll(Arrays.asList(file.listFiles()));
+          } else if (file.getName().endsWith(".tar") ||
+              file.getName().endsWith(".tar.bz2") ||
+              file.getName().endsWith(".tar.xz")) {
+            this.tarballs.add(file);
+          } else {
+            String absolutePath = file.getAbsolutePath();
+            long lastModifiedMillis = file.lastModified();
+            if (this.excludedFilesBefore.containsKey(absolutePath) &&
+                this.excludedFilesBefore.get(absolutePath) ==
+                lastModifiedMillis) {
+              this.excludedFilesAfter.put(absolutePath,
+                  lastModifiedMillis);
+              continue;
+            }
+            this.parsedFilesAfter.put(absolutePath, lastModifiedMillis);
+            DescriptorFileImpl descriptorFile = new DescriptorFileImpl();
+            try {
+              descriptorFile.setDirectory(directory);
+              descriptorFile.setFile(file);
+              descriptorFile.setFileName(file.getAbsolutePath());
+              descriptorFile.setLastModified(lastModifiedMillis);
+              descriptorFile.setDescriptors(this.readFile(file));
+            } catch (DescriptorParseException e) {
+              descriptorFile.setException(e);
+            } catch (IOException e) {
+              descriptorFile.setException(e);
+              abortReading = true;
+            }
+            this.descriptorQueue.add(descriptorFile);
+          }
+        }
+      }
+    }
+    private void readTarballs() {
+      List<File> files = new ArrayList<>(this.tarballs);
+      boolean abortReading = false;
+      while (!abortReading && !files.isEmpty()) {
+        File tarball = files.remove(0);
+        if (!tarball.getName().endsWith(".tar") &&
+            !tarball.getName().endsWith(".tar.bz2") &&
+            !tarball.getName().endsWith(".tar.xz")) {
+          continue;
+        }
+        String absolutePath = tarball.getAbsolutePath();
+        long lastModifiedMillis = tarball.lastModified();
+        if (this.excludedFilesBefore.containsKey(absolutePath) &&
+            this.excludedFilesBefore.get(absolutePath) ==
+            lastModifiedMillis) {
+          this.excludedFilesAfter.put(absolutePath, lastModifiedMillis);
+          continue;
+        }
+        this.parsedFilesAfter.put(absolutePath, lastModifiedMillis);
+        try {
+          FileInputStream in = new FileInputStream(tarball);
+          if (in.available() > 0) {
+            TarArchiveInputStream tais = null;
+            if (tarball.getName().endsWith(".tar.bz2")) {
+              tais = new TarArchiveInputStream(
+                  new BZip2CompressorInputStream(in));
+            } else if (tarball.getName().endsWith(".tar.xz")) {
+              tais = new TarArchiveInputStream(
+                  new XZCompressorInputStream(in));
+            } else if (tarball.getName().endsWith(".tar")) {
+              tais = new TarArchiveInputStream(in);
+            }
+            BufferedInputStream bis = new BufferedInputStream(tais);
+            TarArchiveEntry tae = null;
+            while ((tae = tais.getNextTarEntry()) != null) {
+              if (tae.isDirectory()) {
+                continue;
+              }
+              DescriptorFileImpl descriptorFile =
+                  new DescriptorFileImpl();
+              descriptorFile.setTarball(tarball);
+              descriptorFile.setFileName(tae.getName());
+              descriptorFile.setLastModified(tae.getLastModifiedDate().
+                  getTime());
+              ByteArrayOutputStream baos = new ByteArrayOutputStream();
+              int len;
+              byte[] data = new byte[1024];
+              while ((len = bis.read(data, 0, 1024)) >= 0) {
+                baos.write(data, 0, len);
+              }
+              byte[] rawDescriptorBytes = baos.toByteArray();
+              if (rawDescriptorBytes.length < 1) {
+                continue;
+              }
+              try {
+                String fileName = tae.getName().substring(
+                    tae.getName().lastIndexOf("/") + 1);
+                List<Descriptor> parsedDescriptors =
+                    this.descriptorParser.parseDescriptors(
+                    rawDescriptorBytes, fileName);
+                descriptorFile.setDescriptors(parsedDescriptors);
+              } catch (DescriptorParseException e) {
+                descriptorFile.setException(e);
+              }
+              this.descriptorQueue.add(descriptorFile);
+            }
+          }
+        } catch (IOException e) {
+          abortReading = true;
+        }
+      }
+    }
+    private List<Descriptor> readFile(File file) throws IOException,
+        DescriptorParseException {
+      FileInputStream fis = new FileInputStream(file);
+      BufferedInputStream bis = new BufferedInputStream(fis);
+      ByteArrayOutputStream baos = new ByteArrayOutputStream();
+      int len;
+      byte[] data = new byte[1024];
+      while ((len = bis.read(data, 0, 1024)) >= 0) {
+        baos.write(data, 0, len);
+      }
+      bis.close();
+      byte[] rawDescriptorBytes = baos.toByteArray();
+      return this.descriptorParser.parseDescriptors(rawDescriptorBytes,
+          file.getName());
+    }
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorRequestImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorRequestImpl.java
new file mode 100644
index 0000000..0238f24
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorRequestImpl.java
@@ -0,0 +1,114 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.List;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorRequest;
+
+public class DescriptorRequestImpl implements DescriptorRequest {
+
+  private String requestedResource;
+  protected void setRequestedResource(String requestedResource) {
+    this.requestedResource = requestedResource;
+  }
+  protected String getRequestedResource() {
+    return this.requestedResource;
+  }
+
+  private String descriptorType;
+  protected void setDescriptorType(String descriptorType) {
+    this.descriptorType = descriptorType;
+  }
+  protected String getDescriptorType() {
+    return this.descriptorType;
+  }
+
+  private byte[] responseBytes;
+  protected byte[] getResponseBytes() {
+    return this.responseBytes;
+  }
+  protected void setResponseBytes(byte[] responseBytes) {
+    this.responseBytes = responseBytes;
+  }
+
+  private String requestUrl;
+  @Override
+  public String getRequestUrl() {
+    return this.requestUrl;
+  }
+
+  private String directoryNickname;
+  protected void setDirectoryNickname(String directoryNickname) {
+    this.directoryNickname = directoryNickname;
+  }
+  @Override
+  public String getDirectoryNickname() {
+    return this.directoryNickname;
+  }
+
+  private int responseCode;
+  protected void setResponseCode(int responseCode) {
+    this.responseCode = responseCode;
+  }
+  @Override
+  public int getResponseCode() {
+    return this.responseCode;
+  }
+
+  private long requestStart;
+  protected void setRequestStart(long requestStart) {
+    this.requestStart = requestStart;
+  }
+  @Override
+  public long getRequestStart() {
+    return this.requestStart;
+  }
+
+  private long requestEnd;
+  protected void setRequestEnd(long requestEnd) {
+    this.requestEnd = requestEnd;
+  }
+  @Override
+  public long getRequestEnd() {
+    return this.requestEnd;
+  }
+
+  private boolean connectTimeoutHasExpired;
+  @Override
+  public boolean connectTimeoutHasExpired() {
+    return this.connectTimeoutHasExpired;
+  }
+
+  private boolean readTimeoutHasExpired;
+  @Override
+  public boolean readTimeoutHasExpired() {
+    return this.readTimeoutHasExpired;
+  }
+
+  private boolean globalTimeoutHasExpired;
+  @Override
+  public boolean globalTimeoutHasExpired() {
+    return this.globalTimeoutHasExpired;
+  }
+
+  private List<Descriptor> descriptors;
+  protected void setDescriptors(List<Descriptor> descriptors) {
+    this.descriptors = descriptors;
+  }
+  @Override
+  public List<Descriptor> getDescriptors() {
+    return this.descriptors;
+  }
+
+  private Exception exception;
+  protected void setException(Exception exception) {
+    this.exception = exception;
+  }
+  @Override
+  public Exception getException() {
+    return this.exception;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DirSourceEntryImpl.java b/src/main/java/org/torproject/descriptor/impl/DirSourceEntryImpl.java
new file mode 100644
index 0000000..fb2f5ad
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DirSourceEntryImpl.java
@@ -0,0 +1,218 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.DirSourceEntry;
+
+public class DirSourceEntryImpl implements DirSourceEntry {
+
+  private byte[] dirSourceEntryBytes;
+  @Override
+  public byte[] getDirSourceEntryBytes() {
+    return this.dirSourceEntryBytes;
+  }
+
+  private boolean failUnrecognizedDescriptorLines;
+  private List<String> unrecognizedLines;
+  protected List<String> getAndClearUnrecognizedLines() {
+    List<String> lines = this.unrecognizedLines;
+    this.unrecognizedLines = null;
+    return lines;
+  }
+
+  protected DirSourceEntryImpl(byte[] dirSourceEntryBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    this.dirSourceEntryBytes = dirSourceEntryBytes;
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+    this.initializeKeywords();
+    this.parseDirSourceEntryBytes();
+    this.checkAndClearKeywords();
+  }
+
+  private SortedSet<String> exactlyOnceKeywords, atMostOnceKeywords;
+  private void initializeKeywords() {
+    this.exactlyOnceKeywords = new TreeSet<>();
+    this.exactlyOnceKeywords.add("dir-source");
+    this.exactlyOnceKeywords.add("vote-digest");
+    this.atMostOnceKeywords = new TreeSet<>();
+    this.atMostOnceKeywords.add("contact");
+  }
+
+  private void parsedExactlyOnceKeyword(String keyword)
+      throws DescriptorParseException {
+    if (!this.exactlyOnceKeywords.contains(keyword)) {
+      throw new DescriptorParseException("Duplicate '" + keyword
+          + "' line in dir-source.");
+    }
+    this.exactlyOnceKeywords.remove(keyword);
+  }
+
+  private void parsedAtMostOnceKeyword(String keyword)
+      throws DescriptorParseException {
+    if (!this.atMostOnceKeywords.contains(keyword)) {
+      throw new DescriptorParseException("Duplicate " + keyword + "line "
+          + "in dir-source.");
+    }
+    this.atMostOnceKeywords.remove(keyword);
+  }
+
+  private void checkAndClearKeywords() throws DescriptorParseException {
+    if (!this.exactlyOnceKeywords.isEmpty()) {
+      throw new DescriptorParseException("dir-source does not contain a '"
+          + this.exactlyOnceKeywords.first() + "' line.");
+    }
+    this.exactlyOnceKeywords = null;
+    this.atMostOnceKeywords = null;
+  }
+
+  private void parseDirSourceEntryBytes()
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.dirSourceEntryBytes)).
+        useDelimiter("\n");
+    boolean skipCrypto = false;
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split(" ");
+      switch (parts[0]) {
+      case "dir-source":
+        this.parseDirSourceLine(line);
+        break;
+      case "contact":
+        this.parseContactLine(line);
+        break;
+      case "vote-digest":
+        this.parseVoteDigestLine(line);
+        break;
+      case "-----BEGIN":
+        skipCrypto = true;
+        break;
+      case "-----END":
+        skipCrypto = false;
+        break;
+      default:
+        if (!skipCrypto) {
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in dir-source entry.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  private void parseDirSourceLine(String line)
+      throws DescriptorParseException {
+    this.parsedExactlyOnceKeyword("dir-source");
+    String[] parts = line.split("[ \t]+");
+    if (parts.length != 7) {
+      throw new DescriptorParseException("Invalid line '" + line + "'.");
+    }
+    String nickname = parts[1];
+    if (nickname.endsWith("-legacy")) {
+      nickname = nickname.substring(0, nickname.length()
+          - "-legacy".length());
+      this.isLegacy = true;
+      this.parsedExactlyOnceKeyword("vote-digest");
+    }
+    this.nickname = ParseHelper.parseNickname(line, nickname);
+    this.identity = ParseHelper.parseTwentyByteHexString(line, parts[2]);
+    if (parts[3].length() < 1) {
+      throw new DescriptorParseException("Illegal hostname in '" + line
+          + "'.");
+    }
+    this.hostname = parts[3];
+    this.ip = ParseHelper.parseIpv4Address(line, parts[4]);
+    this.dirPort = ParseHelper.parsePort(line, parts[5]);
+    this.orPort = ParseHelper.parsePort(line, parts[6]);
+  }
+
+  private void parseContactLine(String line)
+      throws DescriptorParseException {
+    this.parsedAtMostOnceKeyword("contact");
+    if (line.length() > "contact ".length()) {
+      this.contactLine = line.substring("contact ".length());
+    } else {
+      this.contactLine = "";
+    }
+  }
+
+  private void parseVoteDigestLine(String line)
+      throws DescriptorParseException {
+    this.parsedExactlyOnceKeyword("vote-digest");
+    String[] parts = line.split("[ \t]+");
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Invalid line '" + line + "'.");
+    }
+    this.voteDigest = ParseHelper.parseTwentyByteHexString(line,
+        parts[1]);
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String identity;
+  @Override
+  public String getIdentity() {
+    return this.identity;
+  }
+
+  private boolean isLegacy;
+  @Override
+  public boolean isLegacy() {
+    return this.isLegacy;
+  }
+
+  private String hostname;
+  @Override
+  public String getHostname() {
+    return this.hostname;
+  }
+
+  private String ip;
+  @Override
+  public String getIp() {
+    return this.ip;
+  }
+
+  private int dirPort;
+  @Override
+  public int getDirPort() {
+    return this.dirPort;
+  }
+
+  private int orPort;
+  @Override
+  public int getOrPort() {
+    return this.orPort;
+  }
+
+  private String contactLine;
+  @Override
+  public String getContactLine() {
+    return this.contactLine;
+  }
+
+  private String voteDigest;
+  @Override
+  public String getVoteDigest() {
+    return this.voteDigest;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DirectoryDownloader.java b/src/main/java/org/torproject/descriptor/impl/DirectoryDownloader.java
new file mode 100644
index 0000000..a27ed76
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DirectoryDownloader.java
@@ -0,0 +1,104 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.zip.InflaterInputStream;
+
+import org.torproject.descriptor.DescriptorParser;
+import org.torproject.descriptor.DescriptorSourceFactory;
+
+/* Download descriptors from one directory authority or mirror.  First,
+ * ask the coordinator thread to create a request, run it, and deliver
+ * the response.  Repeat until the coordinator thread says there are no
+ * further requests to make. */
+public class DirectoryDownloader implements Runnable {
+
+  private String nickname;
+  private String ipPort;
+  private DescriptorParser descriptorParser;
+  protected DirectoryDownloader(String nickname, String ip, int dirPort) {
+    this.nickname = nickname;
+    this.ipPort = ip + ":" + String.valueOf(dirPort);
+    this.descriptorParser =
+        DescriptorSourceFactory.createDescriptorParser();
+  }
+
+  private DownloadCoordinator downloadCoordinator;
+  protected void setDownloadCoordinator(
+      DownloadCoordinator downloadCoordinator) {
+    this.downloadCoordinator = downloadCoordinator;
+  }
+
+  private long connectTimeout;
+  protected void setConnectTimeout(long connectTimeout) {
+    this.connectTimeout = connectTimeout;
+  }
+
+  private long readTimeout;
+  protected void setReadTimeout(long readTimeout) {
+    this.readTimeout = readTimeout;
+  }
+
+  protected void setFailUnrecognizedDescriptorLines(
+      boolean failUnrecognizedDescriptorLines) {
+    this.descriptorParser.setFailUnrecognizedDescriptorLines(
+        failUnrecognizedDescriptorLines);
+  }
+
+  @Override
+  public void run() {
+    boolean keepRunning = true;
+    do {
+      DescriptorRequestImpl request =
+          this.downloadCoordinator.createRequest(this.nickname);
+      if (request != null) {
+        String url = "http://"; + this.ipPort
+            + request.getRequestedResource();
+        request.setRequestStart(System.currentTimeMillis());
+        HttpURLConnection huc = null;
+        try {
+          URL u = new URL(url);
+          huc = (HttpURLConnection) u.openConnection();
+          huc.setConnectTimeout((int) this.connectTimeout);
+          huc.setReadTimeout((int) this.readTimeout);
+          huc.setRequestMethod("GET");
+          huc.connect();
+          int responseCode = huc.getResponseCode();
+          request.setResponseCode(responseCode);
+          if (responseCode == 200) {
+            BufferedInputStream in = new BufferedInputStream(
+                new InflaterInputStream(huc.getInputStream()));
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            int len;
+            byte[] data = new byte[8192];
+            while ((len = in.read(data, 0, 8192)) >= 0) {
+              baos.write(data, 0, len);
+            }
+            in.close();
+            byte[] responseBytes = baos.toByteArray();
+            request.setResponseBytes(responseBytes);
+            request.setRequestEnd(System.currentTimeMillis());
+            request.setDescriptors(this.descriptorParser.parseDescriptors(
+                responseBytes, null));
+          }
+        } catch (Exception e) {
+          request.setException(e);
+          if (huc != null) {
+            huc.disconnect();
+          }
+          /* Stop downloading from this directory if there are any
+           * problems, e.g., refused connections. */
+          keepRunning = false;
+        }
+        this.downloadCoordinator.deliverResponse(request);
+      } else {
+        keepRunning = false;
+      }
+    } while (keepRunning);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DirectoryKeyCertificateImpl.java b/src/main/java/org/torproject/descriptor/impl/DirectoryKeyCertificateImpl.java
new file mode 100644
index 0000000..b62fc8e
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DirectoryKeyCertificateImpl.java
@@ -0,0 +1,308 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.DirectoryKeyCertificate;
+
+/* TODO Add test class. */
+
+public class DirectoryKeyCertificateImpl extends DescriptorImpl
+    implements DirectoryKeyCertificate {
+
+  protected static List<DirectoryKeyCertificate> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<DirectoryKeyCertificate> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DirectoryKeyCertificateImpl.splitRawDescriptorBytes(
+            descriptorsBytes, "dir-key-certificate-version ");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      DirectoryKeyCertificate parsedDescriptor =
+          new DirectoryKeyCertificateImpl(descriptorBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected DirectoryKeyCertificateImpl(byte[] rawDescriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(rawDescriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.parseDescriptorBytes();
+    this.calculateDigest();
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
+        "dir-key-certificate-version,fingerprint,dir-identity-key,"
+        + "dir-key-published,dir-key-expires,dir-signing-key,"
+        + "dir-key-certification").split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
+        "dir-address,dir-key-crosscert").split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkFirstKeyword("dir-key-certificate-version");
+    this.checkLastKeyword("dir-key-certification");
+    this.clearParsedKeywords();
+  }
+
+  private void parseDescriptorBytes() throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
+        useDelimiter("\n");
+    String nextCrypto = "";
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "dir-key-certificate-version":
+        this.parseDirKeyCertificateVersionLine(line, parts);
+        break;
+      case "dir-address":
+        this.parseDirAddressLine(line, parts);
+        break;
+      case "fingerprint":
+        this.parseFingerprintLine(line, parts);
+        break;
+      case "dir-identity-key":
+        this.parseDirIdentityKeyLine(line, parts);
+        nextCrypto = "dir-identity-key";
+        break;
+      case "dir-key-published":
+        this.parseDirKeyPublishedLine(line, parts);
+        break;
+      case "dir-key-expires":
+        this.parseDirKeyExpiresLine(line, parts);
+        break;
+      case "dir-signing-key":
+        this.parseDirSigningKeyLine(line, parts);
+        nextCrypto = "dir-signing-key";
+        break;
+      case "dir-key-crosscert":
+        this.parseDirKeyCrosscertLine(line, parts);
+        nextCrypto = "dir-key-crosscert";
+        break;
+      case "dir-key-certification":
+        this.parseDirKeyCertificationLine(line, parts);
+        nextCrypto = "dir-key-certification";
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        switch (nextCrypto) {
+        case "dir-identity-key":
+          this.dirIdentityKey = cryptoString;
+          break;
+        case "dir-signing-key":
+          this.dirSigningKey = cryptoString;
+          break;
+        case "dir-key-crosscert":
+          this.dirKeyCrosscert = cryptoString;
+          break;
+        case "dir-key-certification":
+          this.dirKeyCertification = cryptoString;
+          break;
+        default:
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in directory key certificate.");
+        }
+        nextCrypto = "";
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else {
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in directory key certificate.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  private void parseDirKeyCertificateVersionLine(String line,
+      String[] parts) throws DescriptorParseException {
+    if (!line.equals("dir-key-certificate-version 3")) {
+      throw new DescriptorParseException("Illegal directory key "
+          + "certificate version number in line '" + line + "'.");
+    }
+    this.dirKeyCertificateVersion = 3;
+  }
+
+  private void parseDirAddressLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2 || parts[1].split(":").length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in directory key certificate.");
+    }
+    this.address = ParseHelper.parseIpv4Address(line,
+        parts[1].split(":")[0]);
+    this.port = ParseHelper.parsePort(line, parts[1].split(":")[1]);
+  }
+
+  private void parseFingerprintLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in directory key certificate.");
+    }
+    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
+        parts[1]);
+  }
+
+  private void parseDirIdentityKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-identity-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseDirKeyPublishedLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.dirKeyPublishedMillis = ParseHelper.parseTimestampAtIndex(line,
+        parts, 1, 2);
+  }
+
+  private void parseDirKeyExpiresLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.dirKeyExpiresMillis = ParseHelper.parseTimestampAtIndex(line,
+        parts, 1, 2);
+  }
+
+  private void parseDirSigningKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-signing-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseDirKeyCrosscertLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-key-crosscert")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseDirKeyCertificationLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-key-certification")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "dir-key-certificate-version ";
+      String sigToken = "\ndir-key-certification\n";
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, sig - start);
+        this.certificateDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-1").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.certificateDigest == null) {
+      throw new DescriptorParseException("Could not calculate "
+          + "certificate digest.");
+    }
+  }
+
+  private int dirKeyCertificateVersion;
+  @Override
+  public int getDirKeyCertificateVersion() {
+    return this.dirKeyCertificateVersion;
+  }
+
+  private String address;
+  @Override
+  public String getAddress() {
+    return this.address;
+  }
+
+  private int port = -1;
+  @Override
+  public int getPort() {
+    return this.port;
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private String dirIdentityKey;
+  @Override
+  public String getDirIdentityKey() {
+    return this.dirIdentityKey;
+  }
+
+  private long dirKeyPublishedMillis;
+  @Override
+  public long getDirKeyPublishedMillis() {
+    return this.dirKeyPublishedMillis;
+  }
+
+  private long dirKeyExpiresMillis;
+  @Override
+  public long getDirKeyExpiresMillis() {
+    return this.dirKeyExpiresMillis;
+  }
+
+  private String dirSigningKey;
+  @Override
+  public String getDirSigningKey() {
+    return this.dirSigningKey;
+  }
+
+  private String dirKeyCrosscert;
+  @Override
+  public String getDirKeyCrosscert() {
+    return this.dirKeyCrosscert;
+  }
+
+  private String dirKeyCertification;
+  @Override
+  public String getDirKeyCertification() {
+    return this.dirKeyCertification;
+  }
+
+  private String certificateDigest;
+  @Override
+  public String getCertificateDigest() {
+    return this.certificateDigest;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DirectorySignatureImpl.java b/src/main/java/org/torproject/descriptor/impl/DirectorySignatureImpl.java
new file mode 100644
index 0000000..a955f62
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DirectorySignatureImpl.java
@@ -0,0 +1,115 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+
+import org.torproject.descriptor.DirectorySignature;
+
+public class DirectorySignatureImpl implements DirectorySignature {
+
+  private byte[] directorySignatureBytes;
+
+  private boolean failUnrecognizedDescriptorLines;
+  private List<String> unrecognizedLines;
+  protected List<String> getAndClearUnrecognizedLines() {
+    List<String> lines = this.unrecognizedLines;
+    this.unrecognizedLines = null;
+    return lines;
+  }
+
+  protected DirectorySignatureImpl(byte[] directorySignatureBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    this.directorySignatureBytes = directorySignatureBytes;
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+    this.parseDirectorySignatureBytes();
+  }
+
+  private void parseDirectorySignatureBytes()
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.directorySignatureBytes)).
+        useDelimiter("\n");
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split(" ", -1);
+      String keyword = parts[0];
+      switch (keyword) {
+      case "directory-signature":
+        int algorithmOffset = 0;
+        switch (parts.length) {
+        case 4:
+          this.algorithm = parts[1];
+          algorithmOffset = 1;
+          break;
+        case 3:
+          break;
+        default:
+          throw new DescriptorParseException("Illegal line '" + line
+              + "'.");
+        }
+        this.identity = ParseHelper.parseHexString(line,
+            parts[1 + algorithmOffset]);
+        this.signingKeyDigest = ParseHelper.parseHexString(
+            line, parts[2 + algorithmOffset]);
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        this.signature = cryptoString;
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else {
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in dir-source entry.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  static final String DEFAULT_ALGORITHM = "sha1";
+
+  private String algorithm;
+  @Override
+  public String getAlgorithm() {
+    return this.algorithm == null ? DEFAULT_ALGORITHM : this.algorithm;
+  }
+
+  private String identity;
+  @Override
+  public String getIdentity() {
+    return this.identity;
+  }
+
+  private String signingKeyDigest;
+  @Override
+  public String getSigningKeyDigest() {
+    return this.signingKeyDigest;
+  }
+
+  private String signature;
+  @Override
+  public String getSignature() {
+    return this.signature;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DownloadCoordinator.java b/src/main/java/org/torproject/descriptor/impl/DownloadCoordinator.java
new file mode 100644
index 0000000..72cfeae
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DownloadCoordinator.java
@@ -0,0 +1,10 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+public interface DownloadCoordinator {
+
+  public DescriptorRequestImpl createRequest(String nickname);
+
+  public void deliverResponse(DescriptorRequestImpl request);
+}
diff --git a/src/main/java/org/torproject/descriptor/impl/DownloadCoordinatorImpl.java b/src/main/java/org/torproject/descriptor/impl/DownloadCoordinatorImpl.java
new file mode 100644
index 0000000..a8e3731
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/DownloadCoordinatorImpl.java
@@ -0,0 +1,298 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorRequest;
+import org.torproject.descriptor.DirSourceEntry;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+
+/* TODO This whole download logic is a mess and needs a cleanup. */
+public class DownloadCoordinatorImpl implements DownloadCoordinator {
+
+  private BlockingIteratorImpl<DescriptorRequest> descriptorQueue =
+      new BlockingIteratorImpl<>();
+  protected Iterator<DescriptorRequest> getDescriptorQueue() {
+    return this.descriptorQueue;
+  }
+
+  private SortedSet<String> runningDirectories;
+  private SortedMap<String, DirectoryDownloader> directoryAuthorities;
+  private SortedMap<String, DirectoryDownloader> directoryMirrors;
+  private boolean downloadConsensusFromAllAuthorities;
+  private boolean includeCurrentReferencedVotes;
+  private long connectTimeoutMillis;
+  private long readTimeoutMillis;
+  private long globalTimeoutMillis;
+  private boolean failUnrecognizedDescriptorLines;
+
+  protected DownloadCoordinatorImpl(
+      SortedMap<String, DirectoryDownloader> directoryAuthorities,
+      SortedMap<String, DirectoryDownloader> directoryMirrors,
+      boolean downloadConsensus,
+      boolean downloadConsensusFromAllAuthorities,
+      Set<String> downloadVotes, boolean includeCurrentReferencedVotes,
+      long connectTimeoutMillis, long readTimeoutMillis,
+      long globalTimeoutMillis, boolean failUnrecognizedDescriptorLines) {
+    this.directoryAuthorities = directoryAuthorities;
+    this.directoryMirrors = directoryMirrors;
+    this.runningDirectories = new TreeSet<>();
+    this.runningDirectories.addAll(directoryAuthorities.keySet());
+    this.runningDirectories.addAll(directoryMirrors.keySet());
+    this.missingConsensus = downloadConsensus;
+    this.downloadConsensusFromAllAuthorities =
+        downloadConsensusFromAllAuthorities;
+    this.missingVotes = downloadVotes;
+    this.includeCurrentReferencedVotes = includeCurrentReferencedVotes;
+    this.connectTimeoutMillis = connectTimeoutMillis;
+    this.readTimeoutMillis = readTimeoutMillis;
+    this.globalTimeoutMillis = globalTimeoutMillis;
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+    if (this.directoryMirrors.isEmpty() &&
+        this.directoryAuthorities.isEmpty()) {
+      this.descriptorQueue.setOutOfDescriptors();
+      /* TODO Should we say anything if we don't have any directories
+       * configured? */
+    } else {
+      GlobalTimer globalTimer = new GlobalTimer(this.globalTimeoutMillis,
+          this);
+      this.globalTimerThread = new Thread(globalTimer);
+      this.globalTimerThread.start();
+      for (DirectoryDownloader directoryMirror :
+          this.directoryMirrors.values()) {
+        directoryMirror.setDownloadCoordinator(this);
+        directoryMirror.setConnectTimeout(this.connectTimeoutMillis);
+        directoryMirror.setReadTimeout(this.readTimeoutMillis);
+        directoryMirror.setFailUnrecognizedDescriptorLines(
+            this.failUnrecognizedDescriptorLines);
+        new Thread(directoryMirror).start();
+      }
+      for (DirectoryDownloader directoryAuthority :
+          this.directoryAuthorities.values()) {
+        directoryAuthority.setDownloadCoordinator(this);
+        directoryAuthority.setConnectTimeout(this.connectTimeoutMillis);
+        directoryAuthority.setReadTimeout(this.readTimeoutMillis);
+        directoryAuthority.setFailUnrecognizedDescriptorLines(
+            this.failUnrecognizedDescriptorLines);
+        new Thread(directoryAuthority).start();
+      }
+    }
+  }
+
+  /* Interrupt all downloads if the total download time exceeds a given
+   * time. */
+  private Thread globalTimerThread;
+  private static class GlobalTimer implements Runnable {
+    private long timeoutMillis;
+    private DownloadCoordinatorImpl downloadCoordinator;
+    private GlobalTimer(long timeoutMillis,
+        DownloadCoordinatorImpl downloadCoordinator) {
+      this.timeoutMillis = timeoutMillis;
+      this.downloadCoordinator = downloadCoordinator;
+    }
+    public void run() {
+      long started = System.currentTimeMillis(), sleep;
+      while ((sleep = started + this.timeoutMillis
+          - System.currentTimeMillis()) > 0L) {
+        try {
+          Thread.sleep(sleep);
+        } catch (InterruptedException e) {
+          return;
+        }
+      }
+      this.downloadCoordinator.interruptAllDownloads();
+    }
+  }
+
+  /* Are we missing the consensus, and should the next directory that
+   * hasn't tried downloading it before attempt to download it? */
+  private boolean missingConsensus = false;
+
+  /* Which directories are currently attempting to download the
+   * consensus? */
+  private Set<String> requestingConsensuses = new HashSet<>();
+
+  /* Which directories have attempted to download the consensus so far,
+   * including those directories that are currently attempting it? */
+  private Set<String> requestedConsensuses = new HashSet<>();
+
+  /* Which votes are we currently missing? */
+  private Set<String> missingVotes = new HashSet<>();
+
+  /* Which vote (map value) is a given directory (map key) currently
+   * attempting to download? */
+  private Map<String, String> requestingVotes = new HashMap<>();
+
+  /* Which votes (map value) has a given directory (map key) attempted or
+   * is currently attempting to download? */
+  private Map<String, Set<String>> requestedVotes = new HashMap<>();
+
+  private boolean hasFinishedDownloading = false;
+
+  /* Look up what request a directory should make next.  If there is
+   * nothing to do right now, but maybe later, block the caller.  If
+   * we're done downloading, return null to notify the caller. */
+  @Override
+  public synchronized DescriptorRequestImpl createRequest(
+      String nickname) {
+    while (!this.hasFinishedDownloading) {
+      DescriptorRequestImpl request = new DescriptorRequestImpl();
+      request.setDirectoryNickname(nickname);
+      if ((this.missingConsensus ||
+          (this.downloadConsensusFromAllAuthorities &&
+          this.directoryAuthorities.containsKey(nickname))) &&
+          !this.requestedConsensuses.contains(nickname)) {
+        if (!this.downloadConsensusFromAllAuthorities) {
+          this.missingConsensus = false;
+        }
+        this.requestingConsensuses.add(nickname);
+        this.requestedConsensuses.add(nickname);
+        request.setRequestedResource(
+            "/tor/status-vote/current/consensus.z");
+        request.setDescriptorType("consensus");
+        return request;
+      }
+      if (!this.missingVotes.isEmpty() &&
+          this.directoryAuthorities.containsKey(nickname)) {
+        String requestingVote = null;
+        for (String missingVote : this.missingVotes) {
+          if (!this.requestedVotes.containsKey(nickname) ||
+              !this.requestedVotes.get(nickname).contains(missingVote)) {
+            requestingVote = missingVote;
+          }
+        }
+        if (requestingVote != null) {
+          this.requestingVotes.put(nickname, requestingVote);
+          if (!this.requestedVotes.containsKey(nickname)) {
+            this.requestedVotes.put(nickname, new HashSet<String>());
+          }
+          this.requestedVotes.get(nickname).add(requestingVote);
+          this.missingVotes.remove(requestingVote);
+          request.setRequestedResource("/tor/status-vote/current/"
+              + requestingVote + ".z");
+          request.setDescriptorType("vote");
+          return request;
+        }
+      }
+      /* TODO Add server descriptors and extra-info descriptors later. */
+      try {
+        this.wait();
+      } catch (InterruptedException e) {
+        /* TODO What shall we do? */
+      }
+    }
+    return null;
+  }
+
+  /* Deliver a response which may either contain one or more descriptors
+   * or a failure response code.  Update the lists of missing descriptors,
+   * decide if there are more descriptors to download, and wake up any
+   * waiting downloader threads. */
+  @Override
+  public synchronized void deliverResponse(
+      DescriptorRequestImpl response) {
+    String nickname = response.getDirectoryNickname();
+    if (response.getException() != null) {
+      this.runningDirectories.remove(nickname);
+    }
+    switch (response.getDescriptorType()) {
+      case "consensus":
+        this.requestingConsensuses.remove(nickname);
+        if (response.getResponseCode() == 200 &&
+            response.getDescriptors() != null) {
+          if (this.includeCurrentReferencedVotes) {
+            /* TODO Only add votes if the consensus is not older than one
+             * hour.  Or does that make no sense? */
+            for (Descriptor parsedDescriptor :
+                response.getDescriptors()) {
+              if (!(parsedDescriptor instanceof
+                  RelayNetworkStatusConsensus)) {
+                continue;
+              }
+              RelayNetworkStatusConsensus parsedConsensus =
+                  (RelayNetworkStatusConsensus) parsedDescriptor;
+              for (DirSourceEntry dirSource :
+                  parsedConsensus.getDirSourceEntries().values()) {
+                String identity = dirSource.getIdentity();
+                if (!this.missingVotes.contains(identity)) {
+                  boolean alreadyRequested = false;
+                  for (Set<String> requestedBefore :
+                      this.requestedVotes.values()) {
+                    if (requestedBefore.contains(identity)) {
+                      alreadyRequested = true;
+                      break;
+                    }
+                  }
+                  if (!alreadyRequested) {
+                    this.missingVotes.add(identity);
+                  }
+                }
+              }
+            }
+            /* TODO Later, add referenced server descriptors. */
+          }
+        } else {
+          this.missingConsensus = true;
+        }
+        break;
+      case "vote":
+        String requestedVote = requestingVotes.remove(nickname);
+        if (response.getResponseCode() != 200) {
+          this.missingVotes.add(requestedVote);
+        }
+    }
+    if (response.getRequestEnd() != 0L) {
+      this.descriptorQueue.add(response);
+    }
+    boolean doneDownloading = true;
+    if ((this.missingConsensus ||
+        this.downloadConsensusFromAllAuthorities) &&
+        (!this.requestedConsensuses.containsAll(
+        this.runningDirectories) ||
+        !this.requestingConsensuses.isEmpty())) {
+      doneDownloading = false;
+    }
+    if (!this.requestingVotes.isEmpty()) {
+      doneDownloading = false;
+    } else if (!this.missingVotes.isEmpty()) {
+      if (!this.requestedVotes.keySet().containsAll(
+          this.runningDirectories)) {
+        doneDownloading = false;
+      } else {
+        for (String missingVote : this.missingVotes) {
+          for (String runningDirectory : this.runningDirectories) {
+            Set<String> reqVotes = this.requestedVotes.get(
+                runningDirectory);
+            if (!reqVotes.contains(missingVote)) {
+              doneDownloading = false;
+            }
+          }
+        }
+      }
+    }
+    if (doneDownloading) {
+      this.hasFinishedDownloading = true;
+      this.globalTimerThread.interrupt();
+      this.descriptorQueue.setOutOfDescriptors();
+    }
+    /* Wake up all waiting downloader threads.  Maybe they can now
+     * download something, or they'll realize we're done downloading. */
+    this.notifyAll();
+  }
+
+  private synchronized void interruptAllDownloads() {
+    this.hasFinishedDownloading = true;
+    this.notifyAll();
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/ExitListEntryImpl.java b/src/main/java/org/torproject/descriptor/impl/ExitListEntryImpl.java
new file mode 100644
index 0000000..efbf31c
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/ExitListEntryImpl.java
@@ -0,0 +1,216 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ExitList;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.ExitListEntry;
+
+public class ExitListEntryImpl implements ExitListEntry, ExitList.Entry {
+
+  private byte[] exitListEntryBytes;
+
+  private boolean failUnrecognizedDescriptorLines;
+  private List<String> unrecognizedLines;
+  protected List<String> getAndClearUnrecognizedLines() {
+    List<String> lines = this.unrecognizedLines;
+    this.unrecognizedLines = null;
+    return lines;
+  }
+
+  @Deprecated
+  private ExitListEntryImpl(String fingerprint, long publishedMillis,
+      long lastStatusMillis, String exitAddress, long scanMillis) {
+    this.fingerprint = fingerprint;
+    this.publishedMillis = publishedMillis;
+    this.lastStatusMillis = lastStatusMillis;
+    this.exitAddresses.put(exitAddress, scanMillis);
+  }
+
+  @Deprecated
+  List<ExitListEntry> oldEntries() {
+    List<ExitListEntry> result = new ArrayList<>();
+    if (this.exitAddresses.size() > 1) {
+      for (Map.Entry<String, Long> entry :
+          this.exitAddresses.entrySet()) {
+        result.add(new ExitListEntryImpl(this.fingerprint,
+            this.publishedMillis, this.lastStatusMillis, entry.getKey(),
+            entry.getValue()));
+      }
+    } else {
+      result.add(this);
+    }
+    return result;
+  }
+
+  protected ExitListEntryImpl(byte[] exitListEntryBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    this.exitListEntryBytes = exitListEntryBytes;
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+    this.initializeKeywords();
+    this.parseExitListEntryBytes();
+    this.checkAndClearKeywords();
+  }
+
+  private SortedSet<String> keywordCountingSet;
+  private void initializeKeywords() {
+    this.keywordCountingSet = new TreeSet<>();
+    this.keywordCountingSet.add("ExitNode");
+    this.keywordCountingSet.add("Published");
+    this.keywordCountingSet.add("LastStatus");
+    this.keywordCountingSet.add("ExitAddress");
+  }
+
+  private void parsedExactlyOnceKeyword(String keyword)
+      throws DescriptorParseException {
+    if (!this.keywordCountingSet.contains(keyword)) {
+      throw new DescriptorParseException("Duplicate '" + keyword
+          + "' line in exit list entry.");
+    }
+    this.keywordCountingSet.remove(keyword);
+  }
+
+  private void checkAndClearKeywords() throws DescriptorParseException {
+    for (String missingKeyword : this.keywordCountingSet) {
+      throw new DescriptorParseException("Missing '" + missingKeyword
+          + "' line in exit list entry.");
+    }
+    this.keywordCountingSet = null;
+  }
+
+  private void parseExitListEntryBytes()
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.exitListEntryBytes)).
+        useDelimiter(ExitList.EOL);
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split(" ");
+      String keyword = parts[0];
+      switch (keyword) {
+        case "ExitNode":
+          this.parseExitNodeLine(line, parts);
+          break;
+        case "Published":
+          this.parsePublishedLine(line, parts);
+          break;
+        case "LastStatus":
+          this.parseLastStatusLine(line, parts);
+          break;
+        case "ExitAddress":
+          this.parseExitAddressLine(line, parts);
+          break;
+        default:
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in exit list entry.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+      }
+    }
+  }
+
+  private void parseExitNodeLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Invalid line '" + line + "' in "
+          + "exit list entry.");
+    }
+    this.parsedExactlyOnceKeyword(parts[0]);
+    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
+        parts[1]);
+  }
+
+  private void parsePublishedLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 3) {
+      throw new DescriptorParseException("Invalid line '" + line + "' in "
+          + "exit list entry.");
+    }
+    this.parsedExactlyOnceKeyword(parts[0]);
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseLastStatusLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 3) {
+      throw new DescriptorParseException("Invalid line '" + line + "' in "
+          + "exit list entry.");
+    }
+    this.parsedExactlyOnceKeyword(parts[0]);
+    this.lastStatusMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseExitAddressLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 4) {
+      throw new DescriptorParseException("Invalid line '" + line + "' in "
+          + "exit list entry.");
+    }
+    this.keywordCountingSet.remove(parts[0]);
+    this.exitAddresses.put(ParseHelper.parseIpv4Address(line, parts[1]),
+        ParseHelper.parseTimestampAtIndex(line, parts, 2, 3));
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private long lastStatusMillis;
+  @Override
+  public long getLastStatusMillis() {
+    return this.lastStatusMillis;
+  }
+
+  private String exitAddress;
+  @Override
+  public String getExitAddress() {
+    if (null == exitAddress) {
+      Map.Entry<String, Long> randomEntry =
+          this.exitAddresses.entrySet().iterator().next();
+      this.exitAddress = randomEntry.getKey();
+      this.scanMillis = randomEntry.getValue();
+    }
+    return this.exitAddress;
+  }
+
+  private Map<String, Long> exitAddresses = new HashMap<>();
+  @Override
+  public Map<String, Long> getExitAddresses(){
+    return new HashMap<>(this.exitAddresses);
+  }
+
+  private long scanMillis;
+  @Override
+  public long getScanMillis() {
+    if (null == exitAddress) {
+      getExitAddress();
+    }
+    return scanMillis;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/ExitListImpl.java b/src/main/java/org/torproject/descriptor/impl/ExitListImpl.java
new file mode 100644
index 0000000..10619ba
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/ExitListImpl.java
@@ -0,0 +1,142 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.TimeZone;
+
+import org.torproject.descriptor.ExitList;
+import org.torproject.descriptor.ExitListEntry;
+
+public class ExitListImpl extends DescriptorImpl implements ExitList {
+
+  protected ExitListImpl(byte[] rawDescriptorBytes, String fileName,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(rawDescriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.splitAndParseExitListEntries(rawDescriptorBytes);
+    this.setPublishedMillisFromFileName(fileName);
+  }
+
+  private void setPublishedMillisFromFileName(String fileName)
+      throws DescriptorParseException {
+    if (this.downloadedMillis == 0L &&
+        fileName.length() == "2012-02-01-04-06-24".length()) {
+      try {
+        SimpleDateFormat fileNameFormat = new SimpleDateFormat(
+            "yyyy-MM-dd-HH-mm-ss");
+        fileNameFormat.setLenient(false);
+        fileNameFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+        this.downloadedMillis = fileNameFormat.parse(fileName).getTime();
+      } catch (ParseException e) {
+        /* Handle below. */
+      }
+    }
+    if (this.downloadedMillis == 0L) {
+      throw new DescriptorParseException("Unrecognized exit list file "
+          + "name '" + fileName + "'.");
+    }
+  }
+
+  private void splitAndParseExitListEntries(byte[] rawDescriptorBytes)
+      throws DescriptorParseException {
+    if (this.rawDescriptorBytes.length == 0) {
+      throw new DescriptorParseException("Descriptor is empty.");
+    }
+    String descriptorString = new String(rawDescriptorBytes);
+    Scanner s = new Scanner(descriptorString).useDelimiter(EOL);
+    StringBuilder sb = new StringBuilder();
+    boolean firstEntry = true;
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.startsWith("@")) { /* Skip annotation. */
+        if (!s.hasNext()) {
+          throw new DescriptorParseException("Descriptor is empty.");
+        } else {
+          line = s.next();
+        }
+      }
+      String[] parts = line.split(" ");
+      String keyword = parts[0];
+      switch (keyword) {
+        case "Downloaded":
+          this.downloadedMillis = ParseHelper.parseTimestampAtIndex(line,
+              parts, 1, 2);
+          break;
+        case "ExitNode":
+          if (!firstEntry) {
+            this.parseExitListEntry(sb.toString().getBytes());
+          } else {
+            firstEntry = false;
+          }
+          sb = new StringBuilder();
+          sb.append(line).append(ExitList.EOL);
+          break;
+        case "Published":
+          sb.append(line).append(ExitList.EOL);
+          break;
+        case "LastStatus":
+          sb.append(line).append(ExitList.EOL);
+          break;
+        case "ExitAddress":
+          sb.append(line).append(ExitList.EOL);
+          break;
+        default:
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in exit list.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+      }
+    }
+    /* Parse the last entry. */
+    this.parseExitListEntry(sb.toString().getBytes());
+  }
+
+  protected void parseExitListEntry(byte[] exitListEntryBytes)
+      throws DescriptorParseException {
+    ExitListEntryImpl exitListEntry = new ExitListEntryImpl(
+        exitListEntryBytes, this.failUnrecognizedDescriptorLines);
+    this.exitListEntries.add(exitListEntry);
+    this.oldExitListEntries.addAll(exitListEntry.oldEntries());
+    List<String> unrecognizedExitListEntryLines = exitListEntry.
+        getAndClearUnrecognizedLines();
+    if (unrecognizedExitListEntryLines != null) {
+      if (this.unrecognizedLines == null) {
+        this.unrecognizedLines = new ArrayList<>();
+      }
+      this.unrecognizedLines.addAll(unrecognizedExitListEntryLines);
+    }
+  }
+
+  private long downloadedMillis;
+  @Override
+  public long getDownloadedMillis() {
+    return this.downloadedMillis;
+  }
+
+  private Set<ExitListEntry> oldExitListEntries = new HashSet<>();
+  @Deprecated
+  @Override
+  public Set<ExitListEntry> getExitListEntries() {
+    return new HashSet<>(this.oldExitListEntries);
+  }
+
+  private Set<ExitList.Entry> exitListEntries = new HashSet<>();
+  @Override
+  public Set<ExitList.Entry> getEntries() {
+    return new HashSet<>(this.exitListEntries);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/ExtraInfoDescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/ExtraInfoDescriptorImpl.java
new file mode 100644
index 0000000..3f72616
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/ExtraInfoDescriptorImpl.java
@@ -0,0 +1,1284 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.BandwidthHistory;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+
+public abstract class ExtraInfoDescriptorImpl extends DescriptorImpl
+    implements ExtraInfoDescriptor {
+
+  protected ExtraInfoDescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.parseDescriptorBytes();
+    this.calculateDigest();
+    this.calculateDigestSha256();
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
+        "extra-info,published").split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> dirreqStatsKeywords = new HashSet<>(Arrays.asList((
+        "dirreq-stats-end,dirreq-v2-ips,dirreq-v3-ips,dirreq-v2-reqs,"
+        + "dirreq-v3-reqs,dirreq-v2-share,dirreq-v3-share,dirreq-v2-resp,"
+        + "dirreq-v3-resp,dirreq-v2-direct-dl,dirreq-v3-direct-dl,"
+        + "dirreq-v2-tunneled-dl,dirreq-v3-tunneled-dl,").split(",")));
+    Set<String> entryStatsKeywords = new HashSet<>(Arrays.asList(
+        "entry-stats-end,entry-ips".split(",")));
+    Set<String> cellStatsKeywords = new HashSet<>(Arrays.asList((
+        "cell-stats-end,cell-processed-cells,cell-queued-cells,"
+        + "cell-time-in-queue,cell-circuits-per-decile").split(",")));
+    Set<String> connBiDirectStatsKeywords = new HashSet<>(
+        Arrays.asList("conn-bi-direct".split(",")));
+    Set<String> exitStatsKeywords = new HashSet<>(Arrays.asList((
+        "exit-stats-end,exit-kibibytes-written,exit-kibibytes-read,"
+        + "exit-streams-opened").split(",")));
+    Set<String> bridgeStatsKeywords = new HashSet<>(Arrays.asList(
+        "bridge-stats-end,bridge-stats-ips".split(",")));
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
+        "identity-ed25519,master-key-ed25519,read-history,write-history,"
+        + "dirreq-read-history,dirreq-write-history,geoip-db-digest,"
+        + "router-sig-ed25519,router-signature,router-digest-sha256,"
+        + "router-digest").split(",")));
+    atMostOnceKeywords.addAll(dirreqStatsKeywords);
+    atMostOnceKeywords.addAll(entryStatsKeywords);
+    atMostOnceKeywords.addAll(cellStatsKeywords);
+    atMostOnceKeywords.addAll(connBiDirectStatsKeywords);
+    atMostOnceKeywords.addAll(exitStatsKeywords);
+    atMostOnceKeywords.addAll(bridgeStatsKeywords);
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkKeywordsDependOn(dirreqStatsKeywords, "dirreq-stats-end");
+    this.checkKeywordsDependOn(entryStatsKeywords, "entry-stats-end");
+    this.checkKeywordsDependOn(cellStatsKeywords, "cell-stats-end");
+    this.checkKeywordsDependOn(exitStatsKeywords, "exit-stats-end");
+    this.checkKeywordsDependOn(bridgeStatsKeywords, "bridge-stats-end");
+    this.checkFirstKeyword("extra-info");
+    this.clearParsedKeywords();
+    return;
+  }
+
+  private void parseDescriptorBytes() throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
+        useDelimiter("\n");
+    String nextCrypto = "";
+    List<String> cryptoLines = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      String lineNoOpt = line.startsWith("opt ") ?
+          line.substring("opt ".length()) : line;
+      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
+      String keyword = partsNoOpt[0];
+      switch (keyword) {
+      case "extra-info":
+        this.parseExtraInfoLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "published":
+        this.parsePublishedLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "read-history":
+        this.parseReadHistoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "write-history":
+        this.parseWriteHistoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "geoip-db-digest":
+        this.parseGeoipDbDigestLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "geoip6-db-digest":
+        this.parseGeoip6DbDigestLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "geoip-start-time":
+        this.parseGeoipStartTimeLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "geoip-client-origins":
+        this.parseGeoipClientOriginsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-stats-end":
+        this.parseDirreqStatsEndLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v2-ips":
+        this.parseDirreqV2IpsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v3-ips":
+        this.parseDirreqV3IpsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v2-reqs":
+        this.parseDirreqV2ReqsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v3-reqs":
+        this.parseDirreqV3ReqsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v2-share":
+        this.parseDirreqV2ShareLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v3-share":
+        this.parseDirreqV3ShareLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v2-resp":
+        this.parseDirreqV2RespLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v3-resp":
+        this.parseDirreqV3RespLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v2-direct-dl":
+        this.parseDirreqV2DirectDlLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v3-direct-dl":
+        this.parseDirreqV3DirectDlLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v2-tunneled-dl":
+        this.parseDirreqV2TunneledDlLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-v3-tunneled-dl":
+        this.parseDirreqV3TunneledDlLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-read-history":
+        this.parseDirreqReadHistoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dirreq-write-history":
+        this.parseDirreqWriteHistoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "entry-stats-end":
+        this.parseEntryStatsEndLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "entry-ips":
+        this.parseEntryIpsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "cell-stats-end":
+        this.parseCellStatsEndLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "cell-processed-cells":
+        this.parseCellProcessedCellsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "cell-queued-cells":
+        this.parseCellQueuedCellsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "cell-time-in-queue":
+        this.parseCellTimeInQueueLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "cell-circuits-per-decile":
+        this.parseCellCircuitsPerDecileLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "conn-bi-direct":
+        this.parseConnBiDirectLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "exit-stats-end":
+        this.parseExitStatsEndLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "exit-kibibytes-written":
+        this.parseExitKibibytesWrittenLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "exit-kibibytes-read":
+        this.parseExitKibibytesReadLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "exit-streams-opened":
+        this.parseExitStreamsOpenedLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "bridge-stats-end":
+        this.parseBridgeStatsEndLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "bridge-ips":
+        this.parseBridgeStatsIpsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "bridge-ip-versions":
+        this.parseBridgeIpVersionsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "bridge-ip-transports":
+        this.parseBridgeIpTransportsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "transport":
+        this.parseTransportLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "hidserv-stats-end":
+        this.parseHidservStatsEndLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "hidserv-rend-relayed-cells":
+        this.parseHidservRendRelayedCellsLine(line, lineNoOpt,
+            partsNoOpt);
+        break;
+      case "hidserv-dir-onions-seen":
+        this.parseHidservDirOnionsSeenLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "identity-ed25519":
+        this.parseIdentityEd25519Line(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "identity-ed25519";
+        break;
+      case "master-key-ed25519":
+        this.parseMasterKeyEd25519Line(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-sig-ed25519":
+        this.parseRouterSigEd25519Line(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-signature":
+        this.parseRouterSignatureLine(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "router-signature";
+        break;
+      case "router-digest":
+        this.parseRouterDigestLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-digest-sha256":
+        this.parseRouterDigestSha256Line(line, lineNoOpt, partsNoOpt);
+        break;
+      case "-----BEGIN":
+        cryptoLines = new ArrayList<>();
+        cryptoLines.add(line);
+        break;
+      case "-----END":
+        cryptoLines.add(line);
+        StringBuilder sb = new StringBuilder();
+        for (String cryptoLine : cryptoLines) {
+          sb.append("\n").append(cryptoLine);
+        }
+        String cryptoString = sb.toString().substring(1);
+        switch (nextCrypto) {
+        case "router-signature":
+          this.routerSignature = cryptoString;
+          break;
+        case "identity-ed25519":
+          this.identityEd25519 = cryptoString;
+          this.parseIdentityEd25519CryptoBlock(cryptoString);
+          break;
+        default:
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized crypto "
+                + "block '" + cryptoString + "' in extra-info "
+                + "descriptor.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.addAll(cryptoLines);
+          }
+          cryptoLines = null;
+          nextCrypto = "";
+        }
+        break;
+      default:
+        if (cryptoLines != null) {
+          cryptoLines.add(line);
+        } else {
+          ParseHelper.parseKeyword(line, partsNoOpt[0]);
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in extra-info descriptor.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  private void parseExtraInfoLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 3) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in extra-info descriptor.");
+    }
+    this.nickname = ParseHelper.parseNickname(line, partsNoOpt[1]);
+    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
+        partsNoOpt[2]);
+  }
+
+  private void parsePublishedLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseReadHistoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.readHistory = new BandwidthHistoryImpl(line, lineNoOpt,
+        partsNoOpt);
+  }
+
+  private void parseWriteHistoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.writeHistory = new BandwidthHistoryImpl(line, lineNoOpt,
+        partsNoOpt);
+  }
+
+  private void parseGeoipDbDigestLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in extra-info descriptor.");
+    }
+    this.geoipDbDigest = ParseHelper.parseTwentyByteHexString(line,
+        partsNoOpt[1]);
+  }
+
+  private void parseGeoip6DbDigestLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in extra-info descriptor.");
+    }
+    this.geoip6DbDigest = ParseHelper.parseTwentyByteHexString(line,
+        partsNoOpt[1]);
+  }
+
+  private void parseGeoipStartTimeLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 3) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in extra-info descriptor.");
+    }
+    this.geoipStartTimeMillis = ParseHelper.parseTimestampAtIndex(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseGeoipClientOriginsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.geoipClientOrigins =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseDirreqStatsEndLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        5);
+    this.dirreqStatsEndMillis = parsedStatsEndData[0];
+    this.dirreqStatsIntervalLength = parsedStatsEndData[1];
+  }
+
+  private long[] parseStatsEndLine(String line, String partsNoOpt[],
+      int partsNoOptExpectedLength) throws DescriptorParseException {
+    if (partsNoOpt.length != partsNoOptExpectedLength ||
+        partsNoOpt[3].length() < 2 || !partsNoOpt[3].startsWith("(") ||
+        !partsNoOpt[4].equals("s)")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    long[] result = new long[2];
+    result[0] = ParseHelper.parseTimestampAtIndex(line, partsNoOpt, 1, 2);
+    result[1] = ParseHelper.parseSeconds(line,
+        partsNoOpt[3].substring(1));
+    if (result[1] <= 0) {
+      throw new DescriptorParseException("Interval length must be "
+          + "positive in line '" + line + "'.");
+    }
+    return result;
+  }
+
+  private void parseDirreqV2IpsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV2Ips = ParseHelper.parseCommaSeparatedKeyIntegerValueList(
+        line, partsNoOpt, 1, 2);
+  }
+
+  private void parseDirreqV3IpsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV3Ips = ParseHelper.parseCommaSeparatedKeyIntegerValueList(
+        line, partsNoOpt, 1, 2);
+  }
+
+  private void parseDirreqV2ReqsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV2Reqs =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseDirreqV3ReqsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV3Reqs =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseDirreqV2ShareLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV2Share = this.parseShareLine(line, partsNoOpt);
+  }
+
+  private void parseDirreqV3ShareLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV3Share = this.parseShareLine(line, partsNoOpt);
+  }
+
+  private double parseShareLine(String line, String[] partsNoOpt)
+      throws DescriptorParseException {
+    double share = -1.0;
+    if (partsNoOpt.length == 2 && partsNoOpt[1].length() >= 2 &&
+        partsNoOpt[1].endsWith("%")) {
+      String shareString = partsNoOpt[1];
+      shareString = shareString.substring(0, shareString.length() - 1);
+      try {
+        share = Double.parseDouble(shareString);
+      } catch (NumberFormatException e) {
+        /* Handle below. */
+      }
+    }
+    if (share < 0.0) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    return share;
+  }
+
+  private void parseDirreqV2RespLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV2Resp =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 0);
+  }
+
+  private void parseDirreqV3RespLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV3Resp =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 0);
+  }
+
+  private void parseDirreqV2DirectDlLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV2DirectDl =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 0);
+  }
+
+  private void parseDirreqV3DirectDlLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV3DirectDl =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 0);
+  }
+
+  private void parseDirreqV2TunneledDlLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV2TunneledDl =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 0);
+  }
+
+  private void parseDirreqV3TunneledDlLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqV3TunneledDl =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(
+        line,partsNoOpt, 1, 0);
+  }
+
+  private void parseDirreqReadHistoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqReadHistory = new BandwidthHistoryImpl(line, lineNoOpt,
+        partsNoOpt);
+  }
+
+  private void parseDirreqWriteHistoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.dirreqWriteHistory = new BandwidthHistoryImpl(line, lineNoOpt,
+        partsNoOpt);
+  }
+
+  private void parseEntryStatsEndLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        5);
+    this.entryStatsEndMillis = parsedStatsEndData[0];
+    this.entryStatsIntervalLength = parsedStatsEndData[1];
+  }
+
+  private void parseEntryIpsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.entryIps = ParseHelper.parseCommaSeparatedKeyIntegerValueList(
+        line, partsNoOpt, 1, 2);
+  }
+
+  private void parseCellStatsEndLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        5);
+    this.cellStatsEndMillis = parsedStatsEndData[0];
+    this.cellStatsIntervalLength = parsedStatsEndData[1];
+  }
+
+  private void parseCellProcessedCellsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.cellProcessedCells = ParseHelper.
+        parseCommaSeparatedIntegerValueList(line, partsNoOpt, 1);
+    if (this.cellProcessedCells.length != 10) {
+      throw new DescriptorParseException("There must be exact ten values "
+          + "in line '" + line + "'.");
+    }
+  }
+
+  private void parseCellQueuedCellsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.cellQueuedCells = ParseHelper.parseCommaSeparatedDoubleValueList(
+        line, partsNoOpt, 1);
+    if (this.cellQueuedCells.length != 10) {
+      throw new DescriptorParseException("There must be exact ten values "
+          + "in line '" + line + "'.");
+    }
+  }
+
+  private void parseCellTimeInQueueLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.cellTimeInQueue = ParseHelper.
+        parseCommaSeparatedIntegerValueList(line, partsNoOpt, 1);
+    if (this.cellTimeInQueue.length != 10) {
+      throw new DescriptorParseException("There must be exact ten values "
+          + "in line '" + line + "'.");
+    }
+  }
+
+  private void parseCellCircuitsPerDecileLine(String line,
+      String lineNoOpt, String[] partsNoOpt)
+      throws DescriptorParseException {
+    int circuits = -1;
+    if (partsNoOpt.length == 2) {
+      try {
+        circuits = Integer.parseInt(partsNoOpt[1]);
+      } catch (NumberFormatException e) {
+        /* Handle below. */
+      }
+    }
+    if (circuits < 0) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.cellCircuitsPerDecile = circuits;
+  }
+
+  private void parseConnBiDirectLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        6);
+    this.connBiDirectStatsEndMillis = parsedStatsEndData[0];
+    this.connBiDirectStatsIntervalLength = parsedStatsEndData[1];
+    Integer[] parsedConnBiDirectStats = ParseHelper.
+        parseCommaSeparatedIntegerValueList(line, partsNoOpt, 5);
+    if (parsedConnBiDirectStats.length != 4) {
+      throw new DescriptorParseException("Illegal line '" + line + "' in "
+          + "extra-info descriptor.");
+    }
+    this.connBiDirectBelow = parsedConnBiDirectStats[0];
+    this.connBiDirectRead = parsedConnBiDirectStats[1];
+    this.connBiDirectWrite = parsedConnBiDirectStats[2];
+    this.connBiDirectBoth = parsedConnBiDirectStats[3];
+  }
+
+  private void parseExitStatsEndLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        5);
+    this.exitStatsEndMillis = parsedStatsEndData[0];
+    this.exitStatsIntervalLength = parsedStatsEndData[1];
+  }
+
+  private void parseExitKibibytesWrittenLine(String line,
+      String lineNoOpt, String[] partsNoOpt)
+      throws DescriptorParseException {
+    this.exitKibibytesWritten = this.sortByPorts(ParseHelper.
+        parseCommaSeparatedKeyLongValueList(line, partsNoOpt, 1, 0));
+    this.verifyPorts(line, this.exitKibibytesWritten.keySet());
+    this.verifyBytesOrStreams(line, this.exitKibibytesWritten.values());
+  }
+
+  private void parseExitKibibytesReadLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.exitKibibytesRead = this.sortByPorts(ParseHelper.
+        parseCommaSeparatedKeyLongValueList(line, partsNoOpt, 1, 0));
+    this.verifyPorts(line, this.exitKibibytesRead.keySet());
+    this.verifyBytesOrStreams(line, this.exitKibibytesRead.values());
+  }
+
+  private void parseExitStreamsOpenedLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.exitStreamsOpened = this.sortByPorts(ParseHelper.
+        parseCommaSeparatedKeyLongValueList(line, partsNoOpt, 1, 0));
+    this.verifyPorts(line, this.exitStreamsOpened.keySet());
+    this.verifyBytesOrStreams(line, this.exitStreamsOpened.values());
+  }
+
+  private SortedMap<String, Long> sortByPorts(
+      SortedMap<String, Long> naturalOrder) {
+    SortedMap<String, Long> byPortNumber =
+        new TreeMap<String, Long>(new Comparator<String>() {
+          public int compare(String arg0, String arg1) {
+            int port0 = 0, port1 = 0;
+            try {
+              port1 = Integer.parseInt(arg1);
+            } catch (NumberFormatException e) {
+              return -1;
+            }
+            try {
+              port0 = Integer.parseInt(arg0);
+            } catch (NumberFormatException e) {
+              return 1;
+            }
+            if (port0 < port1) {
+              return -1;
+            } else if (port0 > port1) {
+              return 1;
+            } else {
+              return 0;
+            }
+          }});
+    byPortNumber.putAll(naturalOrder);
+    return byPortNumber;
+  }
+
+  private void verifyPorts(String line, Set<String> ports)
+      throws DescriptorParseException {
+    boolean valid = true;
+    try {
+      for (String port : ports) {
+        if (!port.equals("other") && Integer.parseInt(port) <= 0) {
+          valid = false;
+          break;
+        }
+      }
+    } catch (NumberFormatException e) {
+      valid = false;
+    }
+    if (!valid) {
+      throw new DescriptorParseException("Invalid port in line '" + line
+          + "'.");
+    }
+  }
+
+  private void verifyBytesOrStreams(String line,
+      Collection<Long> bytesOrStreams) throws DescriptorParseException {
+    boolean valid = true;
+    for (long s : bytesOrStreams) {
+      if (s < 0L) {
+        valid = false;
+        break;
+      }
+    }
+    if (!valid) {
+      throw new DescriptorParseException("Invalid value in line '" + line
+          + "'.");
+    }
+  }
+
+  private void parseBridgeStatsEndLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        5);
+    this.bridgeStatsEndMillis = parsedStatsEndData[0];
+    this.bridgeStatsIntervalLength = parsedStatsEndData[1];
+  }
+
+  private void parseBridgeStatsIpsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.bridgeIps =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseBridgeIpVersionsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.bridgeIpVersions =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseBridgeIpTransportsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.bridgeIpTransports =
+        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
+        partsNoOpt, 1, 0);
+  }
+
+  private void parseTransportLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.transports.add(partsNoOpt[1]);
+  }
+
+  private void parseHidservStatsEndLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
+        5);
+    this.hidservStatsEndMillis = parsedStatsEndData[0];
+    this.hidservStatsIntervalLength = parsedStatsEndData[1];
+  }
+
+  private void parseHidservRendRelayedCellsLine(String line,
+      String lineNoOpt, String[] partsNoOpt)
+      throws DescriptorParseException {
+    if (partsNoOpt.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    try {
+      this.hidservRendRelayedCells = Double.parseDouble(partsNoOpt[1]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.hidservRendRelayedCellsParameters =
+        ParseHelper.parseSpaceSeparatedStringKeyDoubleValueMap(line,
+        partsNoOpt, 2);
+  }
+
+  private void parseHidservDirOnionsSeenLine(String line,
+      String lineNoOpt, String[] partsNoOpt)
+      throws DescriptorParseException {
+    if (partsNoOpt.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    try {
+      this.hidservDirOnionsSeen = Double.parseDouble(partsNoOpt[1]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.hidservDirOnionsSeenParameters =
+        ParseHelper.parseSpaceSeparatedStringKeyDoubleValueMap(line,
+        partsNoOpt, 2);
+  }
+
+  private void parseRouterSignatureLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("router-signature")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseRouterDigestLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.extraInfoDigest = ParseHelper.parseTwentyByteHexString(line,
+        partsNoOpt[1]);
+  }
+
+  private void parseIdentityEd25519Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 1) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseIdentityEd25519CryptoBlock(String cryptoString)
+      throws DescriptorParseException {
+    String masterKeyEd25519FromIdentityEd25519 =
+        ParseHelper.parseMasterKeyEd25519FromIdentityEd25519CryptoBlock(
+        cryptoString);
+    if (this.masterKeyEd25519 != null && !this.masterKeyEd25519.equals(
+        masterKeyEd25519FromIdentityEd25519)) {
+      throw new DescriptorParseException("Mismatch between "
+          + "identity-ed25519 and master-key-ed25519.");
+    }
+    this.masterKeyEd25519 = masterKeyEd25519FromIdentityEd25519;
+  }
+
+  private void parseMasterKeyEd25519Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    String masterKeyEd25519FromMasterKeyEd25519Line = partsNoOpt[1];
+    if (this.masterKeyEd25519 != null && !masterKeyEd25519.equals(
+        masterKeyEd25519FromMasterKeyEd25519Line)) {
+      throw new DescriptorParseException("Mismatch between "
+          + "identity-ed25519 and master-key-ed25519.");
+    }
+    this.masterKeyEd25519 = masterKeyEd25519FromMasterKeyEd25519Line;
+  }
+
+  private void parseRouterSigEd25519Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.routerSignatureEd25519 = partsNoOpt[1];
+  }
+
+  private void parseRouterDigestSha256Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    ParseHelper.parseThirtyTwoByteBase64String(line, partsNoOpt[1]);
+    this.extraInfoDigestSha256 = partsNoOpt[1];
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    if (this.extraInfoDigest != null) {
+      /* We already learned the descriptor digest of this bridge
+       * descriptor from a "router-digest" line. */
+      return;
+    }
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "extra-info ";
+      String sigToken = "\nrouter-signature\n";
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, sig - start);
+        this.extraInfoDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-1").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.extraInfoDigest == null) {
+      throw new DescriptorParseException("Could not calculate extra-info "
+          + "descriptor digest.");
+    }
+  }
+
+  private void calculateDigestSha256() throws DescriptorParseException {
+    if (this.extraInfoDigestSha256 != null) {
+      /* We already learned the descriptor digest of this bridge
+       * descriptor from a "router-digest-sha256" line. */
+      return;
+    }
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "extra-info ";
+      String sigToken = "\n-----END SIGNATURE-----\n";
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start, forDigest,
+            0, sig - start);
+        this.extraInfoDigestSha256 = DatatypeConverter.printBase64Binary(
+            MessageDigest.getInstance("SHA-256").digest(forDigest)).
+            replaceAll("=", "");
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.extraInfoDigestSha256 == null) {
+      throw new DescriptorParseException("Could not calculate extra-info "
+          + "descriptor SHA-256 digest.");
+    }
+  }
+
+  private String extraInfoDigest;
+  @Override
+  public String getExtraInfoDigest() {
+    return this.extraInfoDigest;
+  }
+
+  private String extraInfoDigestSha256;
+  @Override
+  public String getExtraInfoDigestSha256() {
+    return this.extraInfoDigestSha256;
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private BandwidthHistory readHistory;
+  @Override
+  public BandwidthHistory getReadHistory() {
+    return this.readHistory;
+  }
+
+  private BandwidthHistory writeHistory;
+  @Override
+  public BandwidthHistory getWriteHistory() {
+    return this.writeHistory;
+  }
+
+  private String geoipDbDigest;
+  @Override
+  public String getGeoipDbDigest() {
+    return this.geoipDbDigest;
+  }
+
+  private String geoip6DbDigest;
+  @Override
+  public String getGeoip6DbDigest() {
+    return this.geoip6DbDigest;
+  }
+
+  private long dirreqStatsEndMillis = -1L;
+  @Override
+  public long getDirreqStatsEndMillis() {
+    return this.dirreqStatsEndMillis;
+  }
+
+  private long dirreqStatsIntervalLength = -1L;
+  @Override
+  public long getDirreqStatsIntervalLength() {
+    return this.dirreqStatsIntervalLength;
+  }
+
+  private String dirreqV2Ips;
+  @Override
+  public SortedMap<String, Integer> getDirreqV2Ips() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV2Ips);
+  }
+
+  private String dirreqV3Ips;
+  @Override
+  public SortedMap<String, Integer> getDirreqV3Ips() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV3Ips);
+  }
+
+  private String dirreqV2Reqs;
+  @Override
+  public SortedMap<String, Integer> getDirreqV2Reqs() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV2Reqs);
+  }
+
+  private String dirreqV3Reqs;
+  @Override
+  public SortedMap<String, Integer> getDirreqV3Reqs() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV3Reqs);
+  }
+
+  private double dirreqV2Share = -1.0;
+  @Override
+  public double getDirreqV2Share() {
+    return this.dirreqV2Share;
+  }
+
+  private double dirreqV3Share = -1.0;
+  @Override
+  public double getDirreqV3Share() {
+    return this.dirreqV3Share;
+  }
+
+  private String dirreqV2Resp;
+  @Override
+  public SortedMap<String, Integer> getDirreqV2Resp() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV2Resp);
+  }
+
+  private String dirreqV3Resp;
+  @Override
+  public SortedMap<String, Integer> getDirreqV3Resp() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV3Resp);
+  }
+
+  private String dirreqV2DirectDl;
+  @Override
+  public SortedMap<String, Integer> getDirreqV2DirectDl() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV2DirectDl);
+  }
+
+  private String dirreqV3DirectDl;
+  @Override
+  public SortedMap<String, Integer> getDirreqV3DirectDl() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV3DirectDl);
+  }
+
+  private String dirreqV2TunneledDl;
+  @Override
+  public SortedMap<String, Integer> getDirreqV2TunneledDl() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV2TunneledDl);
+  }
+
+  private String dirreqV3TunneledDl;
+  @Override
+  public SortedMap<String, Integer> getDirreqV3TunneledDl() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.dirreqV3TunneledDl);
+  }
+
+  private BandwidthHistory dirreqReadHistory;
+  @Override
+  public BandwidthHistory getDirreqReadHistory() {
+    return this.dirreqReadHistory;
+  }
+
+  private BandwidthHistory dirreqWriteHistory;
+  @Override
+  public BandwidthHistory getDirreqWriteHistory() {
+    return this.dirreqWriteHistory;
+  }
+
+  private long entryStatsEndMillis = -1L;
+  @Override
+  public long getEntryStatsEndMillis() {
+    return this.entryStatsEndMillis;
+  }
+
+  private long entryStatsIntervalLength = -1L;
+  @Override
+  public long getEntryStatsIntervalLength() {
+    return this.entryStatsIntervalLength;
+  }
+
+  private String entryIps;
+  @Override
+  public SortedMap<String, Integer> getEntryIps() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.entryIps);
+  }
+
+  private long cellStatsEndMillis = -1L;
+  @Override
+  public long getCellStatsEndMillis() {
+    return this.cellStatsEndMillis;
+  }
+
+  private long cellStatsIntervalLength = -1L;
+  @Override
+  public long getCellStatsIntervalLength() {
+    return this.cellStatsIntervalLength;
+  }
+
+  private Integer[] cellProcessedCells;
+  @Override
+  public List<Integer> getCellProcessedCells() {
+    return this.cellProcessedCells == null ? null :
+        Arrays.asList(this.cellProcessedCells);
+  }
+
+  private Double[] cellQueuedCells;
+  @Override
+  public List<Double> getCellQueuedCells() {
+    return this.cellQueuedCells == null ? null :
+        Arrays.asList(this.cellQueuedCells);
+  }
+
+  private Integer[] cellTimeInQueue;
+  @Override
+  public List<Integer> getCellTimeInQueue() {
+    return this.cellTimeInQueue == null ? null :
+        Arrays.asList(this.cellTimeInQueue);
+  }
+
+  private int cellCircuitsPerDecile = -1;
+  @Override
+  public int getCellCircuitsPerDecile() {
+    return this.cellCircuitsPerDecile;
+  }
+
+  private long connBiDirectStatsEndMillis = -1L;
+  @Override
+  public long getConnBiDirectStatsEndMillis() {
+    return this.connBiDirectStatsEndMillis;
+  }
+
+  private long connBiDirectStatsIntervalLength = -1L;
+  @Override
+  public long getConnBiDirectStatsIntervalLength() {
+    return this.connBiDirectStatsIntervalLength;
+  }
+
+  private int connBiDirectBelow = -1;
+  @Override
+  public int getConnBiDirectBelow() {
+    return this.connBiDirectBelow;
+  }
+
+  private int connBiDirectRead = -1;
+  @Override
+  public int getConnBiDirectRead() {
+    return this.connBiDirectRead;
+  }
+
+  private int connBiDirectWrite = -1;
+  @Override
+  public int getConnBiDirectWrite() {
+    return this.connBiDirectWrite;
+  }
+
+  private int connBiDirectBoth = -1;
+  @Override
+  public int getConnBiDirectBoth() {
+    return this.connBiDirectBoth;
+  }
+
+  private long exitStatsEndMillis = -1L;
+  @Override
+  public long getExitStatsEndMillis() {
+    return this.exitStatsEndMillis;
+  }
+
+  private long exitStatsIntervalLength = -1L;
+  @Override
+  public long getExitStatsIntervalLength() {
+    return this.exitStatsIntervalLength;
+  }
+
+  private SortedMap<String, Long> exitKibibytesWritten;
+  @Override
+  public SortedMap<String, Long> getExitKibibytesWritten() {
+    return this.exitKibibytesWritten == null ? null :
+        new TreeMap<>(this.exitKibibytesWritten);
+  }
+
+  private SortedMap<String, Long> exitKibibytesRead;
+  @Override
+  public SortedMap<String, Long> getExitKibibytesRead() {
+    return this.exitKibibytesRead == null ? null :
+        new TreeMap<>(this.exitKibibytesRead);
+  }
+
+  private SortedMap<String, Long> exitStreamsOpened;
+  @Override
+  public SortedMap<String, Long> getExitStreamsOpened() {
+    return this.exitStreamsOpened == null ? null :
+        new TreeMap<>(this.exitStreamsOpened);
+  }
+
+  private long geoipStartTimeMillis = -1L;
+  @Override
+  public long getGeoipStartTimeMillis() {
+    return this.geoipStartTimeMillis;
+  }
+
+  private String geoipClientOrigins;
+  @Override
+  public SortedMap<String, Integer> getGeoipClientOrigins() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.geoipClientOrigins);
+  }
+
+  private long bridgeStatsEndMillis = -1L;
+  @Override
+  public long getBridgeStatsEndMillis() {
+    return this.bridgeStatsEndMillis;
+  }
+
+  private long bridgeStatsIntervalLength = -1L;
+  @Override
+  public long getBridgeStatsIntervalLength() {
+    return this.bridgeStatsIntervalLength;
+  }
+
+  private String bridgeIps;
+  @Override
+  public SortedMap<String, Integer> getBridgeIps() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.bridgeIps);
+  }
+
+  private String bridgeIpVersions;
+  @Override
+  public SortedMap<String, Integer> getBridgeIpVersions() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.bridgeIpVersions);
+  }
+
+  private String bridgeIpTransports;
+  @Override
+  public SortedMap<String, Integer> getBridgeIpTransports() {
+    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
+        this.bridgeIpTransports);
+  }
+
+  private List<String> transports = new ArrayList<>();
+  @Override
+  public List<String> getTransports() {
+    return new ArrayList<>(this.transports);
+  }
+
+  private long hidservStatsEndMillis = -1L;
+  @Override
+  public long getHidservStatsEndMillis() {
+    return this.hidservStatsEndMillis;
+  }
+
+  private long hidservStatsIntervalLength = -1L;
+  @Override
+  public long getHidservStatsIntervalLength() {
+    return this.hidservStatsIntervalLength;
+  }
+
+  private Double hidservRendRelayedCells;
+  @Override
+  public Double getHidservRendRelayedCells() {
+    return this.hidservRendRelayedCells;
+  }
+
+  private Map<String, Double> hidservRendRelayedCellsParameters;
+  @Override
+  public Map<String, Double> getHidservRendRelayedCellsParameters() {
+    return this.hidservRendRelayedCellsParameters == null ? null :
+        new HashMap<>(this.hidservRendRelayedCellsParameters);
+  }
+
+  private Double hidservDirOnionsSeen;
+  @Override
+  public Double getHidservDirOnionsSeen() {
+    return this.hidservDirOnionsSeen;
+  }
+
+  private Map<String, Double> hidservDirOnionsSeenParameters;
+  @Override
+  public Map<String, Double> getHidservDirOnionsSeenParameters() {
+    return this.hidservDirOnionsSeenParameters == null ? null :
+      new HashMap<>(this.hidservDirOnionsSeenParameters);
+  }
+
+  private String routerSignature;
+  @Override
+  public String getRouterSignature() {
+    return this.routerSignature;
+  }
+
+  private String identityEd25519;
+  @Override
+  public String getIdentityEd25519() {
+    return this.identityEd25519;
+  }
+
+  private String masterKeyEd25519;
+  @Override
+  public String getMasterKeyEd25519() {
+    return this.masterKeyEd25519;
+  }
+
+  private String routerSignatureEd25519;
+  @Override
+  public String getRouterSignatureEd25519() {
+    return this.routerSignatureEd25519;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/MicrodescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/MicrodescriptorImpl.java
new file mode 100644
index 0000000..4931c31
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/MicrodescriptorImpl.java
@@ -0,0 +1,328 @@
+/* Copyright 2014--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.Microdescriptor;
+
+/* Contains a microdescriptor. */
+public class MicrodescriptorImpl extends DescriptorImpl
+    implements Microdescriptor {
+
+  protected static List<Microdescriptor> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<Microdescriptor> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
+        "onion-key\n");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      Microdescriptor parsedDescriptor =
+          new MicrodescriptorImpl(descriptorBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected MicrodescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.parseDescriptorBytes();
+    this.calculateDigest();
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList(
+        "onion-key".split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
+        "ntor-onion-key,family,p,p6,id").split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkFirstKeyword("onion-key");
+    this.clearParsedKeywords();
+    return;
+  }
+
+  private void parseDescriptorBytes() throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
+        useDelimiter("\n");
+    String nextCrypto = "";
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.startsWith("@")) {
+        continue;
+      }
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "onion-key":
+        this.parseOnionKeyLine(line, parts);
+        nextCrypto = "onion-key";
+        break;
+      case "ntor-onion-key":
+        this.parseNtorOnionKeyLine(line, parts);
+        break;
+      case "a":
+        this.parseALine(line, parts);
+        break;
+      case "family":
+        this.parseFamilyLine(line, parts);
+        break;
+      case "p":
+        this.parsePLine(line, parts);
+        break;
+      case "p6":
+        this.parseP6Line(line, parts);
+        break;
+      case "id":
+        this.parseIdLine(line, parts);
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        if (nextCrypto.equals("onion-key")) {
+          this.onionKey = cryptoString;
+        } else {
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in microdescriptor.");
+        }
+        nextCrypto = "";
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else {
+          ParseHelper.parseKeyword(line, parts[0]);
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in microdescriptor.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  private void parseOnionKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("onion-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseNtorOnionKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.ntorOnionKey = parts[1].replaceAll("=", "");
+  }
+
+  private void parseALine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    /* TODO Add more checks. */
+    /* TODO Add tests. */
+    this.orAddresses.add(parts[1]);
+  }
+
+  private void parseFamilyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    String[] familyEntries = new String[parts.length - 1];
+    for (int i = 1; i < parts.length; i++) {
+      if (parts[i].startsWith("$")) {
+        if (parts[i].contains("=") ^ parts[i].contains("~")) {
+          String separator = parts[i].contains("=") ? "=" : "~";
+          String fingerprint = ParseHelper.parseTwentyByteHexString(line,
+              parts[i].substring(1, parts[i].indexOf(separator)));
+          String nickname = ParseHelper.parseNickname(line,
+              parts[i].substring(parts[i].indexOf(separator) + 1));
+          familyEntries[i - 1] = "$" + fingerprint + separator + nickname;
+        } else {
+          familyEntries[i - 1] = "$"
+              + ParseHelper.parseTwentyByteHexString(line,
+              parts[i].substring(1));
+        }
+      } else {
+        familyEntries[i - 1] = ParseHelper.parseNickname(line, parts[i]);
+      }
+    }
+    this.familyEntries = familyEntries;
+  }
+
+  private void parsePLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.validatePOrP6Line(line, parts);
+    this.defaultPolicy = parts[1];
+    this.portList = parts[2];
+  }
+
+  private void parseP6Line(String line, String[] parts)
+      throws DescriptorParseException {
+    this.validatePOrP6Line(line, parts);
+    this.ipv6DefaultPolicy = parts[1];
+    this.ipv6PortList = parts[2];
+  }
+
+  private void validatePOrP6Line(String line, String[] parts)
+      throws DescriptorParseException {
+    boolean isValid = true;
+    if (parts.length != 3) {
+      isValid = false;
+    } else  {
+      switch (parts[1]) {
+      case "accept":
+      case "reject":
+        String[] ports = parts[2].split(",", -1);
+        for (int i = 0; i < ports.length; i++) {
+          if (ports[i].length() < 1) {
+            isValid = false;
+            break;
+          }
+        }
+        break;
+      default:
+        isValid = false;
+      }
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseIdLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 3) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    } else {
+        switch (parts[1]) {
+        case "ed25519":
+          ParseHelper.parseThirtyTwoByteBase64String(line, parts[2]);
+          this.ed25519Identity = parts[2];
+          break;
+        case "rsa1024":
+          ParseHelper.parseTwentyByteBase64String(line, parts[2]);
+          this.rsa1024Identity = parts[2];
+          break;
+        default:
+          throw new DescriptorParseException("Illegal line '" + line + "'.");
+        }
+    }
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "onion-key\n";
+      int start = ascii.indexOf(startToken);
+      int end = ascii.length();
+      if (start >= 0 && end > start) {
+        byte[] forDigest = new byte[end - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, end - start);
+        this.microdescriptorDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-256").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.microdescriptorDigest == null) {
+      throw new DescriptorParseException("Could not calculate "
+          + "microdescriptor digest.");
+    }
+  }
+
+  private String microdescriptorDigest;
+  @Override
+  public String getMicrodescriptorDigest() {
+    return this.microdescriptorDigest;
+  }
+
+  private String onionKey;
+  @Override
+  public String getOnionKey() {
+    return this.onionKey;
+  }
+
+  private String ntorOnionKey;
+  @Override
+  public String getNtorOnionKey() {
+    return this.ntorOnionKey;
+  }
+
+  private List<String> orAddresses = new ArrayList<>();
+  @Override
+  public List<String> getOrAddresses() {
+    return new ArrayList<>(this.orAddresses);
+  }
+
+  private String[] familyEntries;
+  @Override
+  public List<String> getFamilyEntries() {
+    return this.familyEntries == null ? null :
+        Arrays.asList(this.familyEntries);
+  }
+  private String defaultPolicy;
+  @Override
+  public String getDefaultPolicy() {
+    return this.defaultPolicy;
+  }
+
+  private String portList;
+  @Override
+  public String getPortList() {
+    return this.portList;
+  }
+
+  private String ipv6DefaultPolicy;
+  @Override
+  public String getIpv6DefaultPolicy() {
+    return this.ipv6DefaultPolicy;
+  }
+
+  private String ipv6PortList;
+  @Override
+  public String getIpv6PortList() {
+    return this.ipv6PortList;
+  }
+
+  private String rsa1024Identity;
+  @Override
+  public String getRsa1024Identity() {
+    return this.rsa1024Identity;
+  }
+
+  private String ed25519Identity;
+  @Override
+  public String getEd25519Identity() {
+    return this.ed25519Identity;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/NetworkStatusEntryImpl.java b/src/main/java/org/torproject/descriptor/impl/NetworkStatusEntryImpl.java
new file mode 100644
index 0000000..b73d211
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/NetworkStatusEntryImpl.java
@@ -0,0 +1,382 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.NetworkStatusEntry;
+
+public class NetworkStatusEntryImpl implements NetworkStatusEntry {
+
+  private byte[] statusEntryBytes;
+  @Override
+  public byte[] getStatusEntryBytes() {
+    return this.statusEntryBytes;
+  }
+
+  private boolean microdescConsensus;
+
+  private boolean failUnrecognizedDescriptorLines;
+  private List<String> unrecognizedLines;
+  protected List<String> getAndClearUnrecognizedLines() {
+    List<String> lines = this.unrecognizedLines;
+    this.unrecognizedLines = null;
+    return lines;
+  }
+
+  protected NetworkStatusEntryImpl(byte[] statusEntryBytes,
+      boolean microdescConsensus, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    this.statusEntryBytes = statusEntryBytes;
+    this.microdescConsensus = microdescConsensus;
+    this.failUnrecognizedDescriptorLines =
+        failUnrecognizedDescriptorLines;
+    this.initializeKeywords();
+    this.parseStatusEntryBytes();
+    this.clearAtMostOnceKeywords();
+  }
+
+  private SortedSet<String> atMostOnceKeywords;
+  private void initializeKeywords() {
+    this.atMostOnceKeywords = new TreeSet<>();
+    this.atMostOnceKeywords.add("s");
+    this.atMostOnceKeywords.add("v");
+    this.atMostOnceKeywords.add("w");
+    this.atMostOnceKeywords.add("p");
+  }
+
+  private void parsedAtMostOnceKeyword(String keyword)
+      throws DescriptorParseException {
+    if (!this.atMostOnceKeywords.contains(keyword)) {
+      throw new DescriptorParseException("Duplicate '" + keyword
+          + "' line in status entry.");
+    }
+    this.atMostOnceKeywords.remove(keyword);
+  }
+
+  private void parseStatusEntryBytes() throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.statusEntryBytes)).
+        useDelimiter("\n");
+    String line = null;
+    if (!s.hasNext() || !(line = s.next()).startsWith("r ")) {
+      throw new DescriptorParseException("Status entry must start with "
+          + "an r line.");
+    }
+    String[] rLineParts = line.split("[ \t]+");
+    this.parseRLine(line, rLineParts);
+    while (s.hasNext()) {
+      line = s.next();
+      String[] parts = !line.startsWith("opt ") ? line.split("[ \t]+") :
+          line.substring("opt ".length()).split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "a":
+        this.parseALine(line, parts);
+        break;
+      case "s":
+        this.parseSLine(line, parts);
+        break;
+      case "v":
+        this.parseVLine(line, parts);
+        break;
+      case "w":
+        this.parseWLine(line, parts);
+        break;
+      case "p":
+        this.parsePLine(line, parts);
+        break;
+      case "m":
+        this.parseMLine(line, parts);
+        break;
+      case "id":
+        this.parseIdLine(line, parts);
+        break;
+      default:
+        if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in status entry.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private void parseRLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if ((!this.microdescConsensus && parts.length != 9) ||
+        (this.microdescConsensus && parts.length != 8)) {
+      throw new DescriptorParseException("r line '" + line + "' has "
+          + "fewer space-separated elements than expected.");
+    }
+    this.nickname = ParseHelper.parseNickname(line, parts[1]);
+    this.fingerprint = ParseHelper.parseTwentyByteBase64String(line,
+        parts[2]);
+    int descriptorOffset = 0;
+    if (!this.microdescConsensus) {
+      this.descriptor = ParseHelper.parseTwentyByteBase64String(line,
+          parts[3]);
+      descriptorOffset = 1;
+    }
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        3 + descriptorOffset, 4 + descriptorOffset);
+    this.address = ParseHelper.parseIpv4Address(line,
+        parts[5 + descriptorOffset]);
+    this.orPort = ParseHelper.parsePort(line,
+        parts[6 + descriptorOffset]);
+    this.dirPort = ParseHelper.parsePort(line,
+        parts[7 + descriptorOffset]);
+  }
+
+  private void parseALine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Invalid line '" + line + "' in "
+          + "status entry.");
+    }
+    /* TODO Add more checks. */
+    /* TODO Add tests. */
+    this.orAddresses.add(parts[1]);
+  }
+
+  private static Map<String, Integer> flagIndexes = new HashMap<>();
+  private static Map<Integer, String> flagStrings = new HashMap<>();
+
+  private void parseSLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.parsedAtMostOnceKeyword("s");
+    BitSet flags = new BitSet(flagIndexes.size());
+    for (int i = 1; i < parts.length; i++) {
+      String flag = parts[i];
+      if (!flagIndexes.containsKey(flag)) {
+        flagStrings.put(flagIndexes.size(), flag);
+        flagIndexes.put(flag, flagIndexes.size());
+      }
+      flags.set(flagIndexes.get(flag));
+    }
+    this.flags = flags;
+  }
+
+  private void parseVLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.parsedAtMostOnceKeyword("v");
+    String noOptLine = line;
+    if (noOptLine.startsWith("opt ")) {
+      noOptLine = noOptLine.substring(4);
+    }
+    if (noOptLine.length() < 3) {
+      throw new DescriptorParseException("Invalid line '" + line + "' in "
+          + "status entry.");
+    } else {
+      this.version = noOptLine.substring(2);
+    }
+  }
+
+  private void parseWLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.parsedAtMostOnceKeyword("w");
+    SortedMap<String, Integer> pairs =
+        ParseHelper.parseKeyValueIntegerPairs(line, parts, 1, "=");
+    if (pairs.isEmpty()) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    if (pairs.containsKey("Bandwidth")) {
+      this.bandwidth = pairs.remove("Bandwidth");
+    }
+    if (pairs.containsKey("Measured")) {
+      this.measured = pairs.remove("Measured");
+    }
+    if (pairs.containsKey("Unmeasured")) {
+      this.unmeasured = pairs.remove("Unmeasured") == 1L;
+    }
+    if (!pairs.isEmpty()) {
+      /* Ignore unknown key-value pair. */
+    }
+  }
+
+  private void parsePLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.parsedAtMostOnceKeyword("p");
+    boolean isValid = true;
+    if (parts.length != 3) {
+      isValid = false;
+    } else {
+        switch (parts[1]) {
+          case "accept":
+          case "reject":
+            this.defaultPolicy = parts[1];
+            this.portList = parts[2];
+            String[] ports = parts[2].split(",", -1);
+            for (int i = 0; i < ports.length; i++) {
+              if (ports[i].length() < 1) {
+                isValid = false;
+                break;
+              }
+            }
+            break;
+          default:
+            isValid = false;
+        }
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseMLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (this.microdescriptorDigests == null) {
+      this.microdescriptorDigests = new HashSet<>();
+    }
+    if (parts.length == 2) {
+      this.microdescriptorDigests.add(
+          ParseHelper.parseThirtyTwoByteBase64String(line, parts[1]));
+    } else if (parts.length == 3 && parts[2].length() > 7) {
+      /* 7 == "sha256=".length() */
+      this.microdescriptorDigests.add(
+          ParseHelper.parseThirtyTwoByteBase64String(line,
+          parts[2].substring(7)));
+    }
+  }
+
+  private void parseIdLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 3 || !"ed25519".equals(parts[1])) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    } else if ("none".equals(parts[2])) {
+      this.masterKeyEd25519 = "none";
+    } else {
+      ParseHelper.parseThirtyTwoByteBase64String(line, parts[2]);
+      this.masterKeyEd25519 = parts[2];
+    }
+  }
+
+  private void clearAtMostOnceKeywords() {
+    this.atMostOnceKeywords = null;
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private String descriptor;
+  @Override
+  public String getDescriptor() {
+    return this.descriptor;
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private String address;
+  @Override
+  public String getAddress() {
+    return this.address;
+  }
+
+  private int orPort;
+  @Override
+  public int getOrPort() {
+    return this.orPort;
+  }
+
+  private int dirPort;
+  @Override
+  public int getDirPort() {
+    return this.dirPort;
+  }
+
+  private Set<String> microdescriptorDigests;
+  @Override
+  public Set<String> getMicrodescriptorDigests() {
+    return this.microdescriptorDigests == null ? null :
+        new HashSet<>(this.microdescriptorDigests);
+  }
+
+  private List<String> orAddresses = new ArrayList<>();
+  @Override
+  public List<String> getOrAddresses() {
+    return new ArrayList<>(this.orAddresses);
+  }
+
+  private BitSet flags;
+  @Override
+  public SortedSet<String> getFlags() {
+    SortedSet<String> result = new TreeSet<>();
+    if (this.flags != null) {
+      for (int i = this.flags.nextSetBit(0); i >= 0;
+          i = this.flags.nextSetBit(i + 1)) {
+        result.add(flagStrings.get(i));
+      }
+    }
+    return result;
+  }
+
+  private String version;
+  @Override
+  public String getVersion() {
+    return this.version;
+  }
+
+  private long bandwidth = -1L;
+  @Override
+  public long getBandwidth() {
+    return this.bandwidth;
+  }
+
+  private long measured = -1L;
+  @Override
+  public long getMeasured() {
+    return this.measured;
+  }
+
+  private boolean unmeasured = false;
+  @Override
+  public boolean getUnmeasured() {
+    return this.unmeasured;
+  }
+
+  private String defaultPolicy;
+  @Override
+  public String getDefaultPolicy() {
+    return this.defaultPolicy;
+  }
+
+  private String portList;
+  @Override
+  public String getPortList() {
+    return this.portList;
+  }
+
+  private String masterKeyEd25519;
+  @Override
+  public String getMasterKeyEd25519() {
+    return this.masterKeyEd25519;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/NetworkStatusImpl.java b/src/main/java/org/torproject/descriptor/impl/NetworkStatusImpl.java
new file mode 100644
index 0000000..5fa22c7
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/NetworkStatusImpl.java
@@ -0,0 +1,270 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.descriptor.DirSourceEntry;
+import org.torproject.descriptor.DirectorySignature;
+import org.torproject.descriptor.NetworkStatusEntry;
+
+/* Parse the common parts of v3 consensuses, v3 votes, v3 microdesc
+ * consensuses, v2 statuses, and sanitized bridge network statuses and
+ * delegate the specific parts to the subclasses. */
+public abstract class NetworkStatusImpl extends DescriptorImpl {
+
+  protected NetworkStatusImpl(byte[] rawDescriptorBytes,
+      boolean failUnrecognizedDescriptorLines,
+      boolean containsDirSourceEntries, boolean blankLinesAllowed)
+      throws DescriptorParseException {
+    super(rawDescriptorBytes, failUnrecognizedDescriptorLines,
+        blankLinesAllowed);
+    this.splitAndParseParts(this.rawDescriptorBytes,
+        containsDirSourceEntries);
+  }
+
+  private void splitAndParseParts(byte[] rawDescriptorBytes,
+      boolean containsDirSourceEntries) throws DescriptorParseException {
+    if (this.rawDescriptorBytes.length == 0) {
+      throw new DescriptorParseException("Descriptor is empty.");
+    }
+    String descriptorString = new String(rawDescriptorBytes);
+    int startIndex = 0;
+    int firstDirSourceIndex = !containsDirSourceEntries ? -1 :
+        this.findFirstIndexOfKeyword(descriptorString, "dir-source");
+    int firstRIndex = this.findFirstIndexOfKeyword(descriptorString, "r");
+    int directoryFooterIndex = this.findFirstIndexOfKeyword(
+        descriptorString, "directory-footer");
+    int firstDirectorySignatureIndex = this.findFirstIndexOfKeyword(
+        descriptorString, "directory-signature");
+    int endIndex = descriptorString.length();
+    if (firstDirectorySignatureIndex < 0) {
+      firstDirectorySignatureIndex = endIndex;
+    }
+    if (directoryFooterIndex < 0) {
+      directoryFooterIndex = firstDirectorySignatureIndex;
+    }
+    if (firstRIndex < 0) {
+      firstRIndex = directoryFooterIndex;
+    }
+    if (firstDirSourceIndex < 0) {
+      firstDirSourceIndex = firstRIndex;
+    }
+    if (firstDirSourceIndex > startIndex) {
+      this.parseHeaderBytes(descriptorString, startIndex,
+          firstDirSourceIndex);
+    }
+    if (firstRIndex > firstDirSourceIndex) {
+      this.parseDirSourceBytes(descriptorString, firstDirSourceIndex,
+          firstRIndex);
+    }
+    if (directoryFooterIndex > firstRIndex) {
+      this.parseStatusEntryBytes(descriptorString, firstRIndex,
+          directoryFooterIndex);
+    }
+    if (firstDirectorySignatureIndex > directoryFooterIndex) {
+      this.parseDirectoryFooterBytes(descriptorString,
+          directoryFooterIndex, firstDirectorySignatureIndex);
+    }
+    if (endIndex > firstDirectorySignatureIndex) {
+      this.parseDirectorySignatureBytes(descriptorString,
+          firstDirectorySignatureIndex, endIndex);
+    }
+  }
+
+  private int findFirstIndexOfKeyword(String descriptorString,
+      String keyword) {
+    if (descriptorString.startsWith(keyword)) {
+      return 0;
+    } else if (descriptorString.contains("\n" + keyword + " ")) {
+      return descriptorString.indexOf("\n" + keyword + " ") + 1;
+    } else if (descriptorString.contains("\n" + keyword + "\n")) {
+      return descriptorString.indexOf("\n" + keyword + "\n") + 1;
+    } else {
+      return -1;
+    }
+  }
+
+  private void parseHeaderBytes(String descriptorString, int start,
+      int end) throws DescriptorParseException {
+    byte[] headerBytes = new byte[end - start];
+    System.arraycopy(this.rawDescriptorBytes, start,
+        headerBytes, 0, end - start);
+    this.parseHeader(headerBytes);
+  }
+
+  private void parseDirSourceBytes(String descriptorString, int start,
+      int end) throws DescriptorParseException {
+    List<byte[]> splitDirSourceBytes =
+        this.splitByKeyword(descriptorString, "dir-source", start, end);
+    for (byte[] dirSourceBytes : splitDirSourceBytes) {
+      this.parseDirSource(dirSourceBytes);
+    }
+  }
+
+  private void parseStatusEntryBytes(String descriptorString, int start,
+      int end) throws DescriptorParseException {
+    List<byte[]> splitStatusEntryBytes =
+        this.splitByKeyword(descriptorString, "r", start, end);
+    for (byte[] statusEntryBytes : splitStatusEntryBytes) {
+      this.parseStatusEntry(statusEntryBytes);
+    }
+  }
+
+  private void parseDirectoryFooterBytes(String descriptorString,
+      int start, int end) throws DescriptorParseException {
+    byte[] directoryFooterBytes = new byte[end - start];
+    System.arraycopy(this.rawDescriptorBytes, start,
+        directoryFooterBytes, 0, end - start);
+    this.parseFooter(directoryFooterBytes);
+  }
+
+  private void parseDirectorySignatureBytes(String descriptorString,
+      int start, int end) throws DescriptorParseException {
+    List<byte[]> splitDirectorySignatureBytes = this.splitByKeyword(
+        descriptorString, "directory-signature", start, end);
+    for (byte[] directorySignatureBytes : splitDirectorySignatureBytes) {
+      this.parseDirectorySignature(directorySignatureBytes);
+    }
+  }
+
+  private List<byte[]> splitByKeyword(String descriptorString,
+      String keyword, int start, int end) {
+    List<byte[]> splitParts = new ArrayList<>();
+    int from = start;
+    while (from < end) {
+      int to = descriptorString.indexOf("\n" + keyword + " ", from);
+      if (to < 0) {
+        to = descriptorString.indexOf("\n" + keyword + "\n", from);
+      }
+      if (to < 0) {
+        to = end;
+      } else {
+        to += 1;
+      }
+      byte[] part = new byte[to - from];
+      System.arraycopy(this.rawDescriptorBytes, from, part, 0,
+          to - from);
+      from = to;
+      splitParts.add(part);
+    }
+    return splitParts;
+  }
+
+  protected abstract void parseHeader(byte[] headerBytes)
+      throws DescriptorParseException;
+
+  protected void parseDirSource(byte[] dirSourceBytes)
+      throws DescriptorParseException {
+    DirSourceEntryImpl dirSourceEntry = new DirSourceEntryImpl(
+        dirSourceBytes, this.failUnrecognizedDescriptorLines);
+    this.dirSourceEntries.put(dirSourceEntry.getIdentity(),
+        dirSourceEntry);
+    List<String> unrecognizedDirSourceLines = dirSourceEntry.
+        getAndClearUnrecognizedLines();
+    if (unrecognizedDirSourceLines != null) {
+      if (this.unrecognizedLines == null) {
+        this.unrecognizedLines = new ArrayList<>();
+      }
+      this.unrecognizedLines.addAll(unrecognizedDirSourceLines);
+    }
+  }
+
+  protected String[] parseClientOrServerVersions(String line,
+      String[] parts) throws DescriptorParseException {
+    String[] result = null;
+    switch (parts.length) {
+      case 1:
+        result = new String[0];
+        break;
+      case 2:
+        result = parts[1].split(",", -1);
+        for (String version : result) {
+          if (version.length() < 1) {
+            throw new DescriptorParseException("Illegal versions line '"
+                + line + "'.");
+          }
+        }
+        break;
+      default:
+        throw new DescriptorParseException("Illegal versions line '" + line
+            + "'.");
+    }
+    return result;
+  }
+
+  protected void parseStatusEntry(byte[] statusEntryBytes)
+      throws DescriptorParseException {
+    NetworkStatusEntryImpl statusEntry = new NetworkStatusEntryImpl(
+        statusEntryBytes, false, this.failUnrecognizedDescriptorLines);
+    this.statusEntries.put(statusEntry.getFingerprint(), statusEntry);
+    List<String> unrecognizedStatusEntryLines = statusEntry.
+        getAndClearUnrecognizedLines();
+    if (unrecognizedStatusEntryLines != null) {
+      if (this.unrecognizedLines == null) {
+        this.unrecognizedLines = new ArrayList<>();
+      }
+      this.unrecognizedLines.addAll(unrecognizedStatusEntryLines);
+    }
+  }
+
+  protected abstract void parseFooter(byte[] footerBytes)
+      throws DescriptorParseException;
+
+  protected void parseDirectorySignature(byte[] directorySignatureBytes)
+      throws DescriptorParseException {
+    if (this.signatures == null) {
+      this.signatures = new ArrayList<>();
+    }
+    DirectorySignatureImpl signature = new DirectorySignatureImpl(
+        directorySignatureBytes, failUnrecognizedDescriptorLines);
+    this.signatures.add(signature);
+    List<String> unrecognizedStatusEntryLines = signature.
+        getAndClearUnrecognizedLines();
+    if (unrecognizedStatusEntryLines != null) {
+      if (this.unrecognizedLines == null) {
+        this.unrecognizedLines = new ArrayList<>();
+      }
+      this.unrecognizedLines.addAll(unrecognizedStatusEntryLines);
+    }
+  }
+
+  protected SortedMap<String, DirSourceEntry> dirSourceEntries =
+      new TreeMap<>();
+  public SortedMap<String, DirSourceEntry> getDirSourceEntries() {
+    return new TreeMap<>(this.dirSourceEntries);
+  }
+
+  protected SortedMap<String, NetworkStatusEntry> statusEntries =
+      new TreeMap<>();
+  public SortedMap<String, NetworkStatusEntry> getStatusEntries() {
+    return new TreeMap<>(this.statusEntries);
+  }
+  public boolean containsStatusEntry(String fingerprint) {
+    return this.statusEntries.containsKey(fingerprint);
+  }
+  public NetworkStatusEntry getStatusEntry(String fingerprint) {
+    return this.statusEntries.get(fingerprint);
+  }
+
+  protected List<DirectorySignature> signatures;
+  public List<DirectorySignature> getSignatures() {
+    return this.signatures == null ? null
+        : new ArrayList<>(this.signatures);
+  }
+  public SortedMap<String, DirectorySignature> getDirectorySignatures() {
+    SortedMap<String, DirectorySignature> directorySignatures = null;
+    if (this.signatures != null) {
+      directorySignatures = new TreeMap<>();
+      for (DirectorySignature signature : this.signatures) {
+        directorySignatures.put(signature.getIdentity(), signature);
+      }
+    }
+    return directorySignatures;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/ParseHelper.java b/src/main/java/org/torproject/descriptor/impl/ParseHelper.java
new file mode 100644
index 0000000..82c0813
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/ParseHelper.java
@@ -0,0 +1,567 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.DescriptorParseException;
+
+public class ParseHelper {
+
+  private static Pattern keywordPattern =
+      Pattern.compile("^[A-Za-z0-9-]+$");
+  protected static String parseKeyword(String line, String keyword)
+      throws DescriptorParseException {
+    if (!keywordPattern.matcher(keyword).matches()) {
+      throw new DescriptorParseException("Unrecognized character in "
+          + "keyword '" + keyword + "' in line '" + line + "'.");
+    }
+    return keyword;
+  }
+
+  private static Pattern ipv4Pattern =
+      Pattern.compile("^[0-9\\.]{7,15}$");
+  protected static String parseIpv4Address(String line, String address)
+      throws DescriptorParseException {
+    boolean isValid = true;
+    if (!ipv4Pattern.matcher(address).matches()) {
+      isValid = false;
+    } else {
+      String[] parts = address.split("\\.", -1);
+      if (parts.length != 4) {
+        isValid = false;
+      } else {
+        for (int i = 0; i < 4; i++) {
+          try {
+            int octetValue = Integer.parseInt(parts[i]);
+            if (octetValue < 0 || octetValue > 255) {
+              isValid = false;
+            }
+          } catch (NumberFormatException e) {
+            isValid = false;
+          }
+        }
+      }
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("'" + address + "' in line '"
+          + line + "' is not a valid IPv4 address.");
+    }
+    return address;
+  }
+
+  protected static int parsePort(String line, String portString)
+      throws DescriptorParseException {
+    int port = -1;
+    try {
+      port = Integer.parseInt(portString);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("'" + portString + "' in line '"
+          + line + "' is not a valid port number.");
+    }
+    if (port < 0 || port > 65535) {
+      throw new DescriptorParseException("'" + portString + "' in line '"
+          + line + "' is not a valid port number.");
+    }
+    return port;
+  }
+
+  protected static long parseSeconds(String line, String secondsString)
+      throws DescriptorParseException {
+    try {
+      return Long.parseLong(secondsString);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("'" + secondsString + "' in "
+          + "line '" + line + "' is not a valid time in seconds.");
+    }
+  }
+
+  protected static String parseExitPattern(String line, String exitPattern)
+      throws DescriptorParseException {
+    if (!exitPattern.contains(":")) {
+      throw new DescriptorParseException("'" + exitPattern + "' in line '"
+          + line + "' must contain address and port.");
+    }
+    String[] parts = exitPattern.split(":");
+    String addressPart = parts[0];
+    /* TODO Extend to IPv6. */
+    if (addressPart.equals("*")) {
+      /* Nothing to check. */
+    } else if (addressPart.contains("/")) {
+      String[] addressParts = addressPart.split("/");
+      String address = addressParts[0];
+      String mask = addressParts[1];
+      ParseHelper.parseIpv4Address(line, address);
+      if (addressParts.length != 2) {
+        throw new DescriptorParseException("'" + addressPart + "' in "
+            + "line '" + line + "' is not a valid address part.");
+      }
+      if (mask.contains(".")) {
+        ParseHelper.parseIpv4Address(line, mask);
+      } else {
+        int maskValue = -1;
+        try {
+          maskValue = Integer.parseInt(mask);
+        } catch (NumberFormatException e) {
+          /* Handle below. */
+        }
+        if (maskValue < 0 || maskValue > 32) {
+          throw new DescriptorParseException("'" + mask + "' in line '"
+              + line + "' is not a valid IPv4 mask.");
+        }
+      }
+    } else {
+      ParseHelper.parseIpv4Address(line, addressPart);
+    }
+    String portPart = parts[1];
+    if (portPart.equals("*")) {
+      /* Nothing to check. */
+    } else if (portPart.contains("-")) {
+      String[] portParts = portPart.split("-");
+      String fromPort = portParts[0];
+      ParseHelper.parsePort(line, fromPort);
+      String toPort = portParts[1];
+      ParseHelper.parsePort(line, toPort);
+    } else {
+      ParseHelper.parsePort(line, portPart);
+    }
+    return exitPattern;
+  }
+
+  private static ThreadLocal<Map<String, DateFormat>> dateFormats =
+      new ThreadLocal<Map<String, DateFormat>> () {
+    public Map<String, DateFormat> get() {
+      return super.get();
+    }
+    protected Map<String, DateFormat> initialValue() {
+      return new HashMap<>();
+    }
+    public void remove() {
+      super.remove();
+    }
+    public void set(Map<String, DateFormat> value) {
+      super.set(value);
+    }
+  };
+  static DateFormat getDateFormat(String format) {
+    Map<String, DateFormat> threadDateFormats = dateFormats.get();
+    if (!threadDateFormats.containsKey(format)) {
+      DateFormat dateFormat = new SimpleDateFormat(format, Locale.US);
+      dateFormat.setLenient(false);
+      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+      threadDateFormats.put(format, dateFormat);
+    }
+    return threadDateFormats.get(format);
+  }
+
+  protected static long parseTimestampAtIndex(String line, String[] parts,
+      int dateIndex, int timeIndex) throws DescriptorParseException {
+    if (dateIndex >= parts.length || timeIndex >= parts.length) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a timestamp at the expected position.");
+    }
+    long result = -1L;
+    try {
+      DateFormat dateTimeFormat = getDateFormat("yyyy-MM-dd HH:mm:ss");
+      result = dateTimeFormat.parse(
+          parts[dateIndex] + " " + parts[timeIndex]).getTime();
+    } catch (ParseException e) {
+      /* Leave result at -1L. */
+    }
+    if (result < 0L || result / 1000L > (long) Integer.MAX_VALUE) {
+      throw new DescriptorParseException("Illegal timestamp format in "
+          + "line '" + line + "'.");
+    }
+    return result;
+  }
+
+  protected static long parseDateAtIndex(String line, String[] parts,
+      int dateIndex) throws DescriptorParseException {
+    if (dateIndex >= parts.length) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a date at the expected position.");
+    }
+    long result = -1L;
+    try {
+      DateFormat dateFormat = getDateFormat("yyyy-MM-dd");
+      result = dateFormat.parse(parts[dateIndex]).getTime();
+    } catch (ParseException e) {
+      /* Leave result at -1L. */
+    }
+    if (result < 0L || result / 1000L > (long) Integer.MAX_VALUE) {
+      throw new DescriptorParseException("Illegal date format in line '"
+          + line + "'.");
+    }
+    return result;
+  }
+
+  protected static String parseTwentyByteHexString(String line,
+      String hexString) throws DescriptorParseException {
+    return parseHexString(line, hexString, 40);
+  }
+
+  protected static String parseHexString(String line, String hexString)
+      throws DescriptorParseException {
+    return parseHexString(line, hexString, -1);
+  }
+
+  private static Pattern hexPattern = Pattern.compile("^[0-9a-fA-F]*$");
+  private static String parseHexString(String line, String hexString,
+      int expectedLength) throws DescriptorParseException {
+    if (!hexPattern.matcher(hexString).matches() ||
+        hexString.length() % 2 != 0 ||
+        (expectedLength >= 0 && hexString.length() != expectedLength)) {
+      throw new DescriptorParseException("Illegal hex string in line '"
+          + line + "'.");
+    }
+    return hexString.toUpperCase();
+  }
+
+  protected static SortedMap<String, String> parseKeyValueStringPairs(
+      String line, String[] parts, int startIndex, String separatorString)
+      throws DescriptorParseException {
+    SortedMap<String, String> result = new TreeMap<>();
+    for (int i = startIndex; i < parts.length; i++) {
+      String pair = parts[i];
+      String[] pairParts = pair.split(separatorString);
+      if (pairParts.length != 2) {
+        throw new DescriptorParseException("Illegal key-value pair in "
+            + "line '" + line + "'.");
+      }
+      result.put(pairParts[0], pairParts[1]);
+    }
+    return result;
+  }
+
+  protected static SortedMap<String, Integer> parseKeyValueIntegerPairs(
+      String line, String[] parts, int startIndex, String separatorString)
+      throws DescriptorParseException {
+    SortedMap<String, Integer> result = new TreeMap<>();
+    SortedMap<String, String> keyValueStringPairs =
+        ParseHelper.parseKeyValueStringPairs(line, parts, startIndex,
+        separatorString);
+    for (Map.Entry<String, String> e : keyValueStringPairs.entrySet()) {
+      try {
+        result.put(e.getKey(), Integer.parseInt(e.getValue()));
+      } catch (NumberFormatException ex) {
+        throw new DescriptorParseException("Illegal value in line '"
+            + line + "'.");
+      }
+    }
+    return result;
+  }
+
+  private static Pattern nicknamePattern =
+      Pattern.compile("^[0-9a-zA-Z]{1,19}$");
+  protected static String parseNickname(String line, String nickname)
+      throws DescriptorParseException {
+    if (!nicknamePattern.matcher(nickname).matches()) {
+      throw new DescriptorParseException("Illegal nickname in line '"
+          + line + "'.");
+    }
+    return nickname;
+  }
+
+  protected static boolean parseBoolean(String b, String line)
+      throws DescriptorParseException {
+    switch (b) {
+    case "1":
+      return true;
+    case "0":
+      return false;
+    default:
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private static Pattern twentyByteBase64Pattern =
+      Pattern.compile("^[0-9a-zA-Z+/]{27}$");
+  protected static String parseTwentyByteBase64String(String line,
+      String base64String) throws DescriptorParseException {
+    if (!twentyByteBase64Pattern.matcher(base64String).matches()) {
+      throw new DescriptorParseException("'" + base64String
+          + "' in line '" + line + "' is not a valid base64-encoded "
+          + "20-byte value.");
+    }
+    return DatatypeConverter.printHexBinary(
+        DatatypeConverter.parseBase64Binary(base64String + "=")).
+        toUpperCase();
+  }
+
+  private static Pattern thirtyTwoByteBase64Pattern =
+      Pattern.compile("^[0-9a-zA-Z+/]{43}$");
+  protected static String parseThirtyTwoByteBase64String(String line,
+      String base64String) throws DescriptorParseException {
+    if (!thirtyTwoByteBase64Pattern.matcher(base64String).matches()) {
+      throw new DescriptorParseException("'" + base64String
+          + "' in line '" + line + "' is not a valid base64-encoded "
+          + "32-byte value.");
+    }
+    return DatatypeConverter.printHexBinary(
+        DatatypeConverter.parseBase64Binary(base64String + "=")).
+        toUpperCase();
+  }
+
+  private static Map<Integer, Pattern>
+      commaSeparatedKeyValueListPatterns = new HashMap<>();
+  protected static String parseCommaSeparatedKeyIntegerValueList(
+      String line, String[] partsNoOpt, int index, int keyLength)
+      throws DescriptorParseException {
+    String result = "";
+    if (partsNoOpt.length < index) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a key-value list at index " + index + ".");
+    } else if (partsNoOpt.length > index + 1 ) {
+      throw new DescriptorParseException("Line '" + line + "' contains "
+          + "unrecognized values beyond the expected key-value list at "
+          + "index " + index + ".");
+    } else if (partsNoOpt.length > index) {
+      if (!commaSeparatedKeyValueListPatterns.containsKey(keyLength)) {
+        String keyPattern = "[0-9a-zA-Z?<>\\-_]"
+            + (keyLength == 0 ? "+" : "{" + keyLength + "}");
+        String valuePattern = "\\-?[0-9]{1,9}";
+        String patternString = String.format("^%s=%s(,%s=%s)*$",
+            keyPattern, valuePattern, keyPattern, valuePattern);
+        commaSeparatedKeyValueListPatterns.put(keyLength,
+            Pattern.compile(patternString));
+      }
+      Pattern pattern = commaSeparatedKeyValueListPatterns.get(
+          keyLength);
+      if (pattern.matcher(partsNoOpt[index]).matches()) {
+        result = partsNoOpt[index];
+      } else {
+        throw new DescriptorParseException("Line '" + line + "' "
+            + "contains an illegal key or value.");
+      }
+    }
+    return result;
+  }
+
+  protected static SortedMap<String, Integer>
+      convertCommaSeparatedKeyIntegerValueList(String validatedString) {
+    SortedMap<String, Integer> result = null;
+    if (validatedString != null) {
+      result = new TreeMap<>();
+      if (validatedString.contains("=")) {
+        for (String listElement : validatedString.split(",", -1)) {
+          String[] keyAndValue = listElement.split("=");
+          result.put(keyAndValue[0], Integer.parseInt(keyAndValue[1]));
+        }
+      }
+    }
+    return result;
+  }
+
+  protected static SortedMap<String, Long>
+      parseCommaSeparatedKeyLongValueList(String line,
+      String[] partsNoOpt, int index, int keyLength)
+      throws DescriptorParseException {
+    SortedMap<String, Long> result = new TreeMap<>();
+    if (partsNoOpt.length < index) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a key-value list at index " + index + ".");
+    } else if (partsNoOpt.length > index + 1 ) {
+      throw new DescriptorParseException("Line '" + line + "' contains "
+          + "unrecognized values beyond the expected key-value list at "
+          + "index " + index + ".");
+    } else if (partsNoOpt.length > index) {
+      String[] listElements = partsNoOpt[index].split(",", -1);
+      for (String listElement : listElements) {
+        String[] keyAndValue = listElement.split("=");
+        String key = null;
+        long value = -1;
+        if (keyAndValue.length == 2 && (keyLength == 0 ||
+            keyAndValue[0].length() == keyLength)) {
+          try {
+            value = Long.parseLong(keyAndValue[1]);
+            key = keyAndValue[0];
+          } catch (NumberFormatException e) {
+            /* Handle below. */
+          }
+        }
+        if (key == null) {
+          throw new DescriptorParseException("Line '" + line + "' "
+              + "contains an illegal key or value in list element '"
+              + listElement + "'.");
+        }
+        result.put(key, value);
+      }
+    }
+    return result;
+  }
+
+  protected static Integer[] parseCommaSeparatedIntegerValueList(
+      String line, String[] partsNoOpt, int index)
+      throws DescriptorParseException {
+    Integer[] result = null;
+    if (partsNoOpt.length < index) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a comma-separated value list at index " + index
+          + ".");
+    } else if (partsNoOpt.length > index + 1) {
+      throw new DescriptorParseException("Line '" + line + "' contains "
+          + "unrecognized values beyond the expected comma-separated "
+          + "value list at index " + index + ".");
+    } else if (partsNoOpt.length > index) {
+      String[] listElements = partsNoOpt[index].split(",", -1);
+      result = new Integer[listElements.length];
+      for (int i = 0; i < listElements.length; i++) {
+        try {
+          result[i] = Integer.parseInt(listElements[i]);
+        } catch (NumberFormatException e) {
+          throw new DescriptorParseException("Line '" + line + "' "
+              + "contains an illegal value in list element '"
+              + listElements[i] + "'.");
+        }
+      }
+    }
+    return result;
+  }
+
+  protected static Double[] parseCommaSeparatedDoubleValueList(
+      String line, String[] partsNoOpt, int index)
+      throws DescriptorParseException {
+    Double[] result = null;
+    if (partsNoOpt.length < index) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a comma-separated value list at index " + index
+          + ".");
+    } else if (partsNoOpt.length > index + 1) {
+      throw new DescriptorParseException("Line '" + line + "' contains "
+          + "unrecognized values beyond the expected comma-separated "
+          + "value list at index " + index + ".");
+    } else if (partsNoOpt.length > index) {
+      String[] listElements = partsNoOpt[index].split(",", -1);
+      result = new Double[listElements.length];
+      for (int i = 0; i < listElements.length; i++) {
+        try {
+          result[i] = Double.parseDouble(listElements[i]);
+        } catch (NumberFormatException e) {
+          throw new DescriptorParseException("Line '" + line + "' "
+              + "contains an illegal value in list element '"
+              + listElements[i] + "'.");
+        }
+      }
+    }
+    return result;
+  }
+
+  protected static Map<String, Double>
+      parseSpaceSeparatedStringKeyDoubleValueMap(String line,
+      String[] partsNoOpt, int startIndex)
+      throws DescriptorParseException {
+    Map<String, Double> result = new LinkedHashMap<>();
+    if (partsNoOpt.length < startIndex) {
+      throw new DescriptorParseException("Line '" + line + "' does not "
+          + "contain a key-value list starting at index " + startIndex
+          + ".");
+    }
+    for (int i = startIndex; i < partsNoOpt.length; i++) {
+      String listElement = partsNoOpt[i];
+      String[] keyAndValue = listElement.split("=");
+      String key = null;
+      Double value = null;
+      if (keyAndValue.length == 2) {
+        try {
+          value = Double.parseDouble(keyAndValue[1]);
+          key = keyAndValue[0];
+        } catch (NumberFormatException e) {
+          /* Handle below. */
+        }
+      }
+      if (key == null) {
+        throw new DescriptorParseException("Line '" + line + "' contains "
+            + "an illegal key or value in list element '" + listElement
+            + "'.");
+      }
+      result.put(key, value);
+    }
+    return result;
+  }
+
+  protected static String
+      parseMasterKeyEd25519FromIdentityEd25519CryptoBlock(
+      String identityEd25519CryptoBlock) throws DescriptorParseException {
+    String identityEd25519CryptoBlockNoNewlines =
+        identityEd25519CryptoBlock.replaceAll("\n", "");
+    String beginEd25519CertLine = "-----BEGIN ED25519 CERT-----",
+        endEd25519CertLine = "-----END ED25519 CERT-----";
+    if (!identityEd25519CryptoBlockNoNewlines.startsWith(
+        beginEd25519CertLine)) {
+      throw new DescriptorParseException("Illegal start of "
+          + "identity-ed25519 crypto block '" + identityEd25519CryptoBlock
+          + "'.");
+    }
+    if (!identityEd25519CryptoBlockNoNewlines.endsWith(
+        endEd25519CertLine)) {
+      throw new DescriptorParseException("Illegal end of "
+          + "identity-ed25519 crypto block '" + identityEd25519CryptoBlock
+          + "'.");
+    }
+    String identityEd25519Base64 = identityEd25519CryptoBlockNoNewlines.
+        substring(beginEd25519CertLine.length(),
+        identityEd25519CryptoBlock.length()
+        - endEd25519CertLine.length()).replaceAll("=", "");
+    byte[] identityEd25519 = DatatypeConverter.parseBase64Binary(
+        identityEd25519Base64);
+    if (identityEd25519.length < 40) {
+      throw new DescriptorParseException("Invalid length of "
+          + "identity-ed25519 (in bytes): " + identityEd25519.length);
+    } else if (identityEd25519[0] != 0x01) {
+      throw new DescriptorParseException("Unknown version in "
+          + "identity-ed25519: " + identityEd25519[0]);
+    } else if (identityEd25519[1] != 0x04) {
+      throw new DescriptorParseException("Unknown cert type in "
+          + "identity-ed25519: " + identityEd25519[1]);
+    } else if (identityEd25519[6] != 0x01) {
+      throw new DescriptorParseException("Unknown certified key type in "
+          + "identity-ed25519: " + identityEd25519[1]);
+    } else if (identityEd25519[39] == 0x00) {
+      throw new DescriptorParseException("No extensions in "
+          + "identity-ed25519 (which would contain the encoded "
+          + "master-key-ed25519): " + identityEd25519[39]);
+    } else {
+      int extensionStart = 40;
+      for (int i = 0; i < (int) identityEd25519[39]; i++) {
+        if (identityEd25519.length < extensionStart + 4) {
+          throw new DescriptorParseException("Invalid extension with id "
+              + i + " in identity-ed25519.");
+        }
+        int extensionLength = identityEd25519[extensionStart];
+        extensionLength <<= 8;
+        extensionLength += identityEd25519[extensionStart + 1];
+        int extensionType = identityEd25519[extensionStart + 2];
+        if (extensionLength == 32 && extensionType == 4) {
+          if (identityEd25519.length < extensionStart + 4 + 32) {
+            throw new DescriptorParseException("Invalid extension with "
+                + "id " + i + " in identity-ed25519.");
+          }
+          byte[] masterKeyEd25519 = new byte[32];
+          System.arraycopy(identityEd25519, extensionStart + 4,
+              masterKeyEd25519, 0, masterKeyEd25519.length);
+          String masterKeyEd25519Base64 = DatatypeConverter.
+              printBase64Binary(masterKeyEd25519).replaceAll("=", "");
+          String masterKeyEd25519Base64NoTrailingEqualSigns =
+              masterKeyEd25519Base64.replaceAll("=", "");
+          return masterKeyEd25519Base64NoTrailingEqualSigns;
+        }
+        extensionStart += 4 + extensionLength;
+      }
+    }
+    throw new DescriptorParseException("Unable to locate "
+        + "master-key-ed25519 in identity-ed25519.");
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RelayDirectoryImpl.java b/src/main/java/org/torproject/descriptor/impl/RelayDirectoryImpl.java
new file mode 100644
index 0000000..1ff15cb
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RelayDirectoryImpl.java
@@ -0,0 +1,547 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.RelayDirectory;
+import org.torproject.descriptor.RouterStatusEntry;
+import org.torproject.descriptor.ServerDescriptor;
+
+/* TODO Write unit tests. */
+
+public class RelayDirectoryImpl extends DescriptorImpl
+    implements RelayDirectory {
+
+  protected static List<RelayDirectory> parseDirectories(
+      byte[] directoriesBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<RelayDirectory> parsedDirectories = new ArrayList<>();
+    List<byte[]> splitDirectoriesBytes =
+        DescriptorImpl.splitRawDescriptorBytes(directoriesBytes,
+        "signed-directory\n");
+    for (byte[] directoryBytes : splitDirectoriesBytes) {
+      RelayDirectory parsedDirectory =
+          new RelayDirectoryImpl(directoryBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDirectories.add(parsedDirectory);
+    }
+    return parsedDirectories;
+  }
+
+  protected RelayDirectoryImpl(byte[] directoryBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(directoryBytes, failUnrecognizedDescriptorLines, true);
+    this.splitAndParseParts(rawDescriptorBytes);
+    this.calculateDigest();
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
+        "signed-directory,recommended-software,"
+        + "directory-signature").split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList(
+        "dir-signing-key,running-routers,router-status".split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkFirstKeyword("signed-directory");
+    this.clearParsedKeywords();
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "signed-directory\n";
+      String sigToken = "\ndirectory-signature ";
+      if (!ascii.contains(sigToken)) {
+        return;
+      }
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      sig = ascii.indexOf("\n", sig) + 1;
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, sig - start);
+        this.directoryDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-1").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.directoryDigest == null) {
+      throw new DescriptorParseException("Could not calculate v1 "
+          + "directory digest.");
+    }
+  }
+
+  private void splitAndParseParts(byte[] rawDescriptorBytes)
+      throws DescriptorParseException {
+    if (this.rawDescriptorBytes.length == 0) {
+      throw new DescriptorParseException("Descriptor is empty.");
+    }
+    String descriptorString = new String(rawDescriptorBytes);
+    int startIndex = 0;
+    int firstRouterIndex = this.findFirstIndexOfKeyword(descriptorString,
+        "router");
+    int directorySignatureIndex = this.findFirstIndexOfKeyword(
+        descriptorString, "directory-signature");
+    int endIndex = descriptorString.length();
+    if (directorySignatureIndex < 0) {
+      directorySignatureIndex = endIndex;
+    }
+    if (firstRouterIndex < 0) {
+      firstRouterIndex = directorySignatureIndex;
+    }
+    if (firstRouterIndex > startIndex) {
+      this.parseHeaderBytes(descriptorString, startIndex,
+          firstRouterIndex);
+    }
+    if (directorySignatureIndex > firstRouterIndex) {
+      this.parseServerDescriptorBytes(descriptorString, firstRouterIndex,
+          directorySignatureIndex);
+    }
+    if (endIndex > directorySignatureIndex) {
+      this.parseDirectorySignatureBytes(descriptorString,
+          directorySignatureIndex, endIndex);
+    }
+  }
+
+  private int findFirstIndexOfKeyword(String descriptorString,
+      String keyword) {
+    if (descriptorString.startsWith(keyword)) {
+      return 0;
+    } else if (descriptorString.contains("\n" + keyword + " ")) {
+      return descriptorString.indexOf("\n" + keyword + " ") + 1;
+    } else if (descriptorString.contains("\n" + keyword + "\n")) {
+      return descriptorString.indexOf("\n" + keyword + "\n") + 1;
+    } else {
+      return -1;
+    }
+  }
+
+  private void parseHeaderBytes(String descriptorString, int start,
+      int end) throws DescriptorParseException {
+    byte[] headerBytes = new byte[end - start];
+    System.arraycopy(this.rawDescriptorBytes, start,
+        headerBytes, 0, end - start);
+    this.parseHeader(headerBytes);
+  }
+
+  private void parseServerDescriptorBytes(String descriptorString,
+      int start, int end) throws DescriptorParseException {
+    List<byte[]> splitServerDescriptorBytes =
+        this.splitByKeyword(descriptorString, "router", start, end);
+    for (byte[] statusEntryBytes : splitServerDescriptorBytes) {
+      this.parseServerDescriptor(statusEntryBytes);
+    }
+  }
+
+  private void parseDirectorySignatureBytes(String descriptorString,
+      int start, int end) throws DescriptorParseException {
+    List<byte[]> splitDirectorySignatureBytes = this.splitByKeyword(
+        descriptorString, "directory-signature", start, end);
+    for (byte[] directorySignatureBytes : splitDirectorySignatureBytes) {
+      this.parseDirectorySignature(directorySignatureBytes);
+    }
+  }
+
+  private List<byte[]> splitByKeyword(String descriptorString,
+      String keyword, int start, int end) {
+    List<byte[]> splitParts = new ArrayList<>();
+    int from = start;
+    while (from < end) {
+      int to = descriptorString.indexOf("\n" + keyword + " ", from);
+      if (to < 0) {
+        to = descriptorString.indexOf("\n" + keyword + "\n", from);
+      }
+      if (to < 0) {
+        to = end;
+      } else {
+        to += 1;
+      }
+      int toNoNewline = to;
+      while (toNoNewline > from &&
+          descriptorString.charAt(toNoNewline - 1) == '\n') {
+        toNoNewline--;
+      }
+      byte[] part = new byte[toNoNewline - from];
+      System.arraycopy(this.rawDescriptorBytes, from, part, 0,
+          toNoNewline - from);
+      from = to;
+      splitParts.add(part);
+    }
+    return splitParts;
+  }
+
+  private void parseHeader(byte[] headerBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
+    String publishedLine = null, nextCrypto = "",
+        runningRoutersLine = null, routerStatusLine = null;
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.isEmpty() || line.startsWith("@")) {
+        continue;
+      }
+      String lineNoOpt = line.startsWith("opt ") ?
+          line.substring("opt ".length()) : line;
+      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
+      String keyword = partsNoOpt[0];
+      switch (keyword) {
+      case "signed-directory":
+        this.parseSignedDirectoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "published":
+        if (publishedLine != null) {
+          throw new DescriptorParseException("Keyword 'published' is "
+              + "contained more than once, but must be contained exactly "
+              + "once.");
+        } else {
+          publishedLine = line;
+        }
+        break;
+      case "dir-signing-key":
+        this.parseDirSigningKeyLine(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "dir-signing-key";
+        break;
+      case "recommended-software":
+        this.parseRecommendedSoftwareLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "running-routers":
+        runningRoutersLine = line;
+        break;
+      case "router-status":
+        routerStatusLine = line;
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        if (nextCrypto.equals("dir-signing-key") &&
+            this.dirSigningKey == null) {
+          this.dirSigningKey = cryptoString;
+        } else {
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in v1 directory.");
+        }
+        nextCrypto = "";
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else {
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in v1 directory.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+    if (publishedLine == null) {
+      throw new DescriptorParseException("Keyword 'published' is "
+          + "contained 0 times, but must be contained exactly once.");
+    } else {
+      String publishedLineNoOpt = publishedLine.startsWith("opt ") ?
+          publishedLine.substring("opt ".length()) : publishedLine;
+      String[] publishedPartsNoOpt = publishedLineNoOpt.split("[ \t]+");
+      this.parsePublishedLine(publishedLine, publishedLineNoOpt,
+          publishedPartsNoOpt);
+    }
+    if (routerStatusLine != null) {
+      String routerStatusLineNoOpt = routerStatusLine.startsWith("opt ") ?
+          routerStatusLine.substring("opt ".length()) : routerStatusLine;
+      String[] routerStatusPartsNoOpt =
+          routerStatusLineNoOpt.split("[ \t]+");
+      this.parseRouterStatusLine(routerStatusLine, routerStatusLineNoOpt,
+          routerStatusPartsNoOpt);
+    } else if (runningRoutersLine != null) {
+      String runningRoutersLineNoOpt =
+          runningRoutersLine.startsWith("opt ") ?
+          runningRoutersLine.substring("opt ".length()) :
+          runningRoutersLine;
+      String[] runningRoutersPartsNoOpt =
+          runningRoutersLineNoOpt.split("[ \t]+");
+      this.parseRunningRoutersLine(runningRoutersLine,
+          runningRoutersLineNoOpt, runningRoutersPartsNoOpt);
+    } else {
+      throw new DescriptorParseException("Either running-routers or "
+          + "router-status line must be given.");
+    }
+  }
+
+  protected void parseServerDescriptor(byte[] serverDescriptorBytes) {
+    try {
+      ServerDescriptorImpl serverDescriptor =
+          new RelayServerDescriptorImpl(serverDescriptorBytes,
+          this.failUnrecognizedDescriptorLines);
+      this.serverDescriptors.add(serverDescriptor);
+    } catch (DescriptorParseException e) {
+      this.serverDescriptorParseExceptions.add(e);
+    }
+  }
+
+  private void parseDirectorySignature(byte[] directorySignatureBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(directorySignatureBytes)).
+        useDelimiter("\n");
+    String nextCrypto = "";
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      String lineNoOpt = line.startsWith("opt ") ?
+          line.substring("opt ".length()) : line;
+      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
+      String keyword = partsNoOpt[0];
+      switch (keyword) {
+      case "directory-signature":
+        this.parseDirectorySignatureLine(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "directory-signature";
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        if (nextCrypto.equals("directory-signature")) {
+          this.directorySignature = cryptoString;
+        } else {
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in v2 network status.");
+        }
+        nextCrypto = "";
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in v2 network status.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private void parseSignedDirectoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("signed-directory")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parsePublishedLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseDirSigningKeyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length > 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    } else if (partsNoOpt.length == 2) {
+      /* Early directories didn't have a crypto object following the
+       * "dir-signing-key" line, but had the key base64-encoded in the
+       * same line. */
+      StringBuilder sb = new StringBuilder();
+      sb.append("-----BEGIN RSA PUBLIC KEY-----\n");
+      String keyString = partsNoOpt[1];
+      while (keyString.length() > 64) {
+        sb.append(keyString.substring(0, 64)).append("\n");
+        keyString = keyString.substring(64);
+      }
+      if (keyString.length() > 0) {
+        sb.append(keyString).append("\n");
+      }
+      sb.append("-----END RSA PUBLIC KEY-----\n");
+      this.dirSigningKey = sb.toString();
+    }
+  }
+
+  private void parseRecommendedSoftwareLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    List<String> result = new ArrayList<>();
+    if (partsNoOpt.length > 2) {
+      throw new DescriptorParseException("Illegal versions line '" + line
+          + "'.");
+    } else if (partsNoOpt.length == 2) {
+      String[] versions = partsNoOpt[1].split(",", -1);
+      for (int i = 0; i < versions.length; i++) {
+        String version = versions[i];
+        if (version.length() < 1) {
+          throw new DescriptorParseException("Illegal versions line '"
+              + line + "'.");
+        }
+        result.add(version);
+      }
+    }
+    this.recommendedSoftware = result;
+  }
+
+  private void parseRunningRoutersLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    for (int i = 1; i < partsNoOpt.length; i++) {
+      String part = partsNoOpt[i];
+      String debugLine = "running-routers [...] " + part + " [...]";
+      boolean isLive = true;
+      if (part.startsWith("!")) {
+        isLive = false;
+        part = part.substring(1);
+      }
+      boolean isVerified;
+      String fingerprint = null, nickname = null;
+      if (part.startsWith("$")) {
+        isVerified = false;
+        fingerprint = ParseHelper.parseTwentyByteHexString(debugLine,
+            part.substring(1));
+      } else {
+        isVerified = true;
+        nickname = ParseHelper.parseNickname(debugLine, part);
+      }
+      this.statusEntries.add(new RouterStatusEntryImpl(fingerprint,
+          nickname, isLive, isVerified));
+    }
+  }
+
+  private void parseRouterStatusLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    for (int i = 1; i < partsNoOpt.length; i++) {
+      String part = partsNoOpt[i];
+      String debugLine = "router-status [...] " + part + " [...]";
+      RouterStatusEntry entry = null;
+      if (part.contains("=")) {
+        String[] partParts = part.split("=");
+        if (partParts.length == 2) {
+          boolean isVerified = true, isLive;
+          String nickname;
+          if (partParts[0].startsWith("!")) {
+            isLive = false;
+            nickname = ParseHelper.parseNickname(debugLine,
+                partParts[0].substring(1));
+          } else {
+            isLive = true;
+            nickname = ParseHelper.parseNickname(debugLine, partParts[0]);
+          }
+          String fingerprint = ParseHelper.parseTwentyByteHexString(
+              debugLine, partParts[1].substring(1));
+          entry = new RouterStatusEntryImpl(fingerprint, nickname, isLive,
+              isVerified);
+        }
+      } else {
+        boolean isVerified = false, isLive;
+        String nickname = null, fingerprint;
+        if (part.startsWith("!")) {
+          isLive = false;
+          fingerprint = ParseHelper.parseTwentyByteHexString(
+              debugLine, part.substring(2));
+        } else {
+          isLive = true;
+          fingerprint = ParseHelper.parseTwentyByteHexString(
+              debugLine, part.substring(1));;
+        }
+        entry = new RouterStatusEntryImpl(fingerprint, nickname, isLive,
+            isVerified);
+      }
+      if (entry == null) {
+        throw new DescriptorParseException("Illegal router-status entry '"
+            + part + "' in v1 directory.");
+      }
+      this.statusEntries.add(entry);
+    }
+  }
+
+  private void parseDirectorySignatureLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.nickname = ParseHelper.parseNickname(line, partsNoOpt[1]);
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private String dirSigningKey;
+  @Override
+  public String getDirSigningKey() {
+    return this.dirSigningKey;
+  }
+
+  private List<String> recommendedSoftware;
+  @Override
+  public List<String> getRecommendedSoftware() {
+    return this.recommendedSoftware == null ? null :
+        new ArrayList<>(this.recommendedSoftware);
+  }
+
+  private String directorySignature;
+  @Override
+  public String getDirectorySignature() {
+    return this.directorySignature;
+  }
+
+  private List<RouterStatusEntry> statusEntries = new ArrayList<>();
+  @Override
+  public List<RouterStatusEntry> getRouterStatusEntries() {
+    return new ArrayList<>(this.statusEntries);
+  }
+
+  private List<ServerDescriptor> serverDescriptors = new ArrayList<>();
+  @Override
+  public List<ServerDescriptor> getServerDescriptors() {
+    return new ArrayList<>(this.serverDescriptors);
+  }
+
+  private List<Exception> serverDescriptorParseExceptions =
+      new ArrayList<>();
+  @Override
+  public List<Exception> getServerDescriptorParseExceptions() {
+    return new ArrayList<>(this.serverDescriptorParseExceptions);
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String directoryDigest;
+  @Override
+  public String getDirectoryDigest() {
+    return this.directoryDigest;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RelayExtraInfoDescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/RelayExtraInfoDescriptorImpl.java
new file mode 100644
index 0000000..73d4dfa
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RelayExtraInfoDescriptorImpl.java
@@ -0,0 +1,37 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+import org.torproject.descriptor.RelayExtraInfoDescriptor;
+
+public class RelayExtraInfoDescriptorImpl
+    extends ExtraInfoDescriptorImpl implements RelayExtraInfoDescriptor {
+
+  protected static List<ExtraInfoDescriptor> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<ExtraInfoDescriptor> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
+        "extra-info ");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      ExtraInfoDescriptor parsedDescriptor =
+          new RelayExtraInfoDescriptorImpl(descriptorBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected RelayExtraInfoDescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImpl.java b/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImpl.java
new file mode 100644
index 0000000..fe045c1
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImpl.java
@@ -0,0 +1,414 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+
+/* Contains a network status consensus or microdesc consensus. */
+public class RelayNetworkStatusConsensusImpl extends NetworkStatusImpl
+    implements RelayNetworkStatusConsensus {
+
+  protected static List<RelayNetworkStatusConsensus> parseConsensuses(
+      byte[] consensusesBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<RelayNetworkStatusConsensus> parsedConsensuses =
+        new ArrayList<>();
+    List<byte[]> splitConsensusBytes =
+        DescriptorImpl.splitRawDescriptorBytes(consensusesBytes,
+        "network-status-version 3");
+    for (byte[] consensusBytes : splitConsensusBytes) {
+      RelayNetworkStatusConsensus parsedConsensus =
+          new RelayNetworkStatusConsensusImpl(consensusBytes,
+              failUnrecognizedDescriptorLines);
+      parsedConsensuses.add(parsedConsensus);
+    }
+    return parsedConsensuses;
+  }
+
+  protected RelayNetworkStatusConsensusImpl(byte[] consensusBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(consensusBytes, failUnrecognizedDescriptorLines, true, false);
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
+        "vote-status,consensus-method,valid-after,fresh-until,"
+        + "valid-until,voting-delay,known-flags").split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
+        "client-versions,server-versions,params,directory-footer,"
+        + "bandwidth-weights").split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkFirstKeyword("network-status-version");
+    this.clearParsedKeywords();
+    this.calculateDigest();
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "network-status-version ";
+      String sigToken = "\ndirectory-signature ";
+      if (!ascii.contains(sigToken)) {
+        return;
+      }
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, sig - start);
+        this.consensusDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-1").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.consensusDigest == null) {
+      throw new DescriptorParseException("Could not calculate consensus "
+          + "digest.");
+    }
+  }
+
+  protected void parseHeader(byte[] headerBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "network-status-version":
+        this.parseNetworkStatusVersionLine(line, parts);
+        break;
+      case "vote-status":
+        this.parseVoteStatusLine(line, parts);
+        break;
+      case "consensus-method":
+        this.parseConsensusMethodLine(line, parts);
+        break;
+      case "valid-after":
+        this.parseValidAfterLine(line, parts);
+        break;
+      case "fresh-until":
+        this.parseFreshUntilLine(line, parts);
+        break;
+      case "valid-until":
+        this.parseValidUntilLine(line, parts);
+        break;
+      case "voting-delay":
+        this.parseVotingDelayLine(line, parts);
+        break;
+      case "client-versions":
+        this.parseClientVersionsLine(line, parts);
+        break;
+      case "server-versions":
+        this.parseServerVersionsLine(line, parts);
+        break;
+      case "package":
+        this.parsePackageLine(line, parts);
+        break;
+      case "known-flags":
+        this.parseKnownFlagsLine(line, parts);
+        break;
+      case "params":
+        this.parseParamsLine(line, parts);
+        break;
+      default:
+        if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in consensus.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private boolean microdescConsensus = false;
+  protected void parseStatusEntry(byte[] statusEntryBytes)
+      throws DescriptorParseException {
+    NetworkStatusEntryImpl statusEntry = new NetworkStatusEntryImpl(
+        statusEntryBytes, this.microdescConsensus,
+        this.failUnrecognizedDescriptorLines);
+    this.statusEntries.put(statusEntry.getFingerprint(), statusEntry);
+    List<String> unrecognizedStatusEntryLines = statusEntry.
+        getAndClearUnrecognizedLines();
+    if (unrecognizedStatusEntryLines != null) {
+      if (this.unrecognizedLines == null) {
+        this.unrecognizedLines = new ArrayList<>();
+      }
+      this.unrecognizedLines.addAll(unrecognizedStatusEntryLines);
+    }
+  }
+
+  protected void parseFooter(byte[] footerBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(footerBytes)).useDelimiter("\n");
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "directory-footer":
+        break;
+      case "bandwidth-weights":
+        this.parseBandwidthWeightsLine(line, parts);
+        break;
+      default:
+        if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in consensus.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private void parseNetworkStatusVersionLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.startsWith("network-status-version 3")) {
+      throw new DescriptorParseException("Illegal network status version "
+          + "number in line '" + line + "'.");
+    }
+    this.networkStatusVersion = 3;
+    if (parts.length == 3) {
+      this.consensusFlavor = parts[2];
+      if (this.consensusFlavor.equals("microdesc")) {
+        this.microdescConsensus = true;
+      }
+    } else if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal network status version "
+          + "line '" + line + "'.");
+    }
+  }
+
+  private void parseVoteStatusLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2 || !parts[1].equals("consensus")) {
+      throw new DescriptorParseException("Line '" + line + "' indicates "
+          + "that this is not a consensus.");
+    }
+  }
+
+  private void parseConsensusMethodLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in consensus.");
+    }
+    try {
+      this.consensusMethod = Integer.parseInt(parts[1]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal consensus method "
+          + "number in line '" + line + "'.");
+    }
+    if (this.consensusMethod < 1) {
+      throw new DescriptorParseException("Illegal consensus method "
+          + "number in line '" + line + "'.");
+    }
+  }
+
+  private void parseValidAfterLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.validAfterMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseFreshUntilLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.freshUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseValidUntilLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.validUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseVotingDelayLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 3) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    try {
+      this.voteSeconds = Long.parseLong(parts[1]);
+      this.distSeconds = Long.parseLong(parts[2]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal values in line '" + line
+          + "'.");
+    }
+  }
+
+  private void parseClientVersionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.recommendedClientVersions = this.parseClientOrServerVersions(
+        line, parts);
+  }
+
+  private void parseServerVersionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.recommendedServerVersions = this.parseClientOrServerVersions(
+        line, parts);
+  }
+
+  private void parsePackageLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 5) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    if (this.packageLines == null) {
+      this.packageLines = new ArrayList<>();
+    }
+    this.packageLines.add(line.substring("package ".length()));
+  }
+
+  private void parseKnownFlagsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 2) {
+      throw new DescriptorParseException("No known flags in line '" + line
+          + "'.");
+    }
+    String[] knownFlags = new String[parts.length - 1];
+    for (int i = 1; i < parts.length; i++) {
+      knownFlags[i - 1] = parts[i];
+    }
+    this.knownFlags = knownFlags;
+  }
+
+  private void parseParamsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.consensusParams = ParseHelper.parseKeyValueIntegerPairs(line,
+        parts, 1, "=");
+  }
+
+  private void parseBandwidthWeightsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.bandwidthWeights = ParseHelper.parseKeyValueIntegerPairs(line,
+        parts, 1, "=");
+  }
+
+  private String consensusDigest;
+  @Override
+  public String getConsensusDigest() {
+    return this.consensusDigest;
+  }
+
+  private int networkStatusVersion;
+  @Override
+  public int getNetworkStatusVersion() {
+    return this.networkStatusVersion;
+  }
+
+  private String consensusFlavor;
+  @Override
+  public String getConsensusFlavor() {
+    return this.consensusFlavor;
+  }
+
+  private int consensusMethod;
+  @Override
+  public int getConsensusMethod() {
+    return this.consensusMethod;
+  }
+
+  private long validAfterMillis;
+  @Override
+  public long getValidAfterMillis() {
+    return this.validAfterMillis;
+  }
+
+  private long freshUntilMillis;
+  @Override
+  public long getFreshUntilMillis() {
+    return this.freshUntilMillis;
+  }
+
+  private long validUntilMillis;
+  @Override
+  public long getValidUntilMillis() {
+    return this.validUntilMillis;
+  }
+
+  private long voteSeconds;
+  @Override
+  public long getVoteSeconds() {
+    return this.voteSeconds;
+  }
+
+  private long distSeconds;
+  @Override
+  public long getDistSeconds() {
+    return this.distSeconds;
+  }
+
+  private String[] recommendedClientVersions;
+  @Override
+  public List<String> getRecommendedClientVersions() {
+    return this.recommendedClientVersions == null ? null :
+        Arrays.asList(this.recommendedClientVersions);
+  }
+
+  private String[] recommendedServerVersions;
+  @Override
+  public List<String> getRecommendedServerVersions() {
+    return this.recommendedServerVersions == null ? null :
+        Arrays.asList(this.recommendedServerVersions);
+  }
+
+  private List<String> packageLines;
+  @Override
+  public List<String> getPackageLines() {
+    return this.packageLines == null ? null
+        : new ArrayList<>(this.packageLines);
+  }
+
+  private String[] knownFlags;
+  @Override
+  public SortedSet<String> getKnownFlags() {
+    return new TreeSet<>(Arrays.asList(this.knownFlags));
+  }
+
+  private SortedMap<String, Integer> consensusParams;
+  @Override
+  public SortedMap<String, Integer> getConsensusParams() {
+    return this.consensusParams == null ? null:
+        new TreeMap<>(this.consensusParams);
+  }
+
+  private SortedMap<String, Integer> bandwidthWeights;
+  @Override
+  public SortedMap<String, Integer> getBandwidthWeights() {
+    return this.bandwidthWeights == null ? null :
+        new TreeMap<>(this.bandwidthWeights);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusImpl.java b/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusImpl.java
new file mode 100644
index 0000000..a5469db
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusImpl.java
@@ -0,0 +1,384 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.RelayNetworkStatus;
+
+/* TODO Write unit tests. */
+
+public class RelayNetworkStatusImpl extends NetworkStatusImpl
+    implements RelayNetworkStatus {
+
+  protected static List<RelayNetworkStatus> parseStatuses(
+      byte[] statusesBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<RelayNetworkStatus> parsedStatuses = new ArrayList<>();
+    List<byte[]> splitStatusBytes =
+        DescriptorImpl.splitRawDescriptorBytes(statusesBytes,
+        "network-status-version 2");
+    for (byte[] statusBytes : splitStatusBytes) {
+      RelayNetworkStatus parsedStatus = new RelayNetworkStatusImpl(
+          statusBytes, failUnrecognizedDescriptorLines);
+      parsedStatuses.add(parsedStatus);
+    }
+    return parsedStatuses;
+  }
+
+  protected RelayNetworkStatusImpl(byte[] statusBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(statusBytes, failUnrecognizedDescriptorLines, false, true);
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
+        "network-status-version,dir-source,fingerprint,contact,"
+        + "dir-signing-key,published").split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList(
+        "dir-options,client-versions,server-versions".split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkFirstKeyword("network-status-version");
+    this.clearParsedKeywords();
+    this.calculateDigest();
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "network-status-version ";
+      String sigToken = "\ndirectory-signature ";
+      if (!ascii.contains(sigToken)) {
+        return;
+      }
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      sig = ascii.indexOf("\n", sig) + 1;
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, sig - start);
+        this.statusDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-1").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.statusDigest == null) {
+      throw new DescriptorParseException("Could not calculate status "
+          + "digest.");
+    }
+  }
+
+  protected void parseHeader(byte[] headerBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
+    String nextCrypto = "";
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.isEmpty()) {
+        continue;
+      }
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "network-status-version":
+        this.parseNetworkStatusVersionLine(line, parts);
+        break;
+      case "dir-source":
+        this.parseDirSourceLine(line, parts);
+        break;
+      case "fingerprint":
+        this.parseFingerprintLine(line, parts);
+        break;
+      case "contact":
+        this.parseContactLine(line, parts);
+        break;
+      case "dir-signing-key":
+        this.parseDirSigningKeyLine(line, parts);
+        nextCrypto = "dir-signing-key";
+        break;
+      case "client-versions":
+        this.parseClientVersionsLine(line, parts);
+        break;
+      case "server-versions":
+        this.parseServerVersionsLine(line, parts);
+        break;
+      case "published":
+        this.parsePublishedLine(line, parts);
+        break;
+      case "dir-options":
+        this.parseDirOptionsLine(line, parts);
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        if (nextCrypto.equals("dir-signing-key")) {
+          this.dirSigningKey = cryptoString;
+        } else {
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in v2 network status.");
+        }
+        nextCrypto = "";
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in v2 network status.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  protected void parseFooter(byte[] footerBytes)
+      throws DescriptorParseException {
+    throw new DescriptorParseException("No directory footer expected in "
+        + "v2 network status.");
+  }
+
+  protected void parseDirectorySignature(byte[] directorySignatureBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(directorySignatureBytes)).
+        useDelimiter("\n");
+    String nextCrypto = "";
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "directory-signature":
+        this.parseDirectorySignatureLine(line, parts);
+        nextCrypto = "directory-signature";
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        if (nextCrypto.equals("directory-signature")) {
+          this.directorySignature = cryptoString;
+        } else {
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in v2 network status.");
+        }
+        nextCrypto = "";
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '" + line
+              + "' in v2 network status.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private void parseNetworkStatusVersionLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("network-status-version 2")) {
+      throw new DescriptorParseException("Illegal network status version "
+          + "number in line '" + line + "'.");
+    }
+    this.networkStatusVersion = 2;
+  }
+
+  private void parseDirSourceLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 4) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in v2 network status.");
+    }
+    if (parts[1].length() < 1) {
+      throw new DescriptorParseException("Illegal hostname in '" + line
+          + "'.");
+    }
+    this.address = ParseHelper.parseIpv4Address(line, parts[2]);
+    this.dirPort = ParseHelper.parsePort(line, parts[3]);
+  }
+
+
+  private void parseFingerprintLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in v2 network status.");
+    }
+    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
+        parts[1]);
+  }
+
+  private void parseContactLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (line.length() > "contact ".length()) {
+      this.contactLine = line.substring("contact ".length());
+    } else {
+      this.contactLine = "";
+    }
+  }
+
+  private void parseDirSigningKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-signing-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseClientVersionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.recommendedClientVersions = this.parseClientOrServerVersions(
+        line, parts);
+  }
+
+  private void parseServerVersionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.recommendedServerVersions = this.parseClientOrServerVersions(
+        line, parts);
+  }
+
+  private void parsePublishedLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseDirOptionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    String[] dirOptions = new String[parts.length - 1];
+    for (int i = 1; i < parts.length; i++) {
+      dirOptions[i - 1] = parts[i];
+    }
+    this.dirOptions = dirOptions;
+  }
+
+  private void parseDirectorySignatureLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.nickname = ParseHelper.parseNickname(line, parts[1]);
+  }
+
+  private String statusDigest;
+  @Override
+  public String getStatusDigest() {
+    return this.statusDigest;
+  }
+
+  private int networkStatusVersion;
+  @Override
+  public int getNetworkStatusVersion() {
+    return this.networkStatusVersion;
+  }
+
+  private String hostname;
+  @Override
+  public String getHostname() {
+    return this.hostname;
+  }
+
+  private String address;
+  @Override
+  public String getAddress() {
+    return this.address;
+  }
+
+  private int dirPort;
+  @Override
+  public int getDirport() {
+    return this.dirPort;
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private String contactLine;
+  @Override
+  public String getContactLine() {
+    return this.contactLine;
+  }
+
+  private String dirSigningKey;
+  @Override
+  public String getDirSigningKey() {
+    return this.dirSigningKey;
+  }
+
+  private String[] recommendedClientVersions;
+  @Override
+  public List<String> getRecommendedClientVersions() {
+    return this.recommendedClientVersions == null ? null :
+        Arrays.asList(this.recommendedClientVersions);
+  }
+
+  private String[] recommendedServerVersions;
+  @Override
+  public List<String> getRecommendedServerVersions() {
+    return this.recommendedServerVersions == null ? null :
+        Arrays.asList(this.recommendedServerVersions);
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private String[] dirOptions;
+  @Override
+  public SortedSet<String> getDirOptions() {
+    return new TreeSet<>(Arrays.asList(this.dirOptions));
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String directorySignature;
+  @Override
+  public String getDirectorySignature() {
+    return this.directorySignature;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusVoteImpl.java b/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusVoteImpl.java
new file mode 100644
index 0000000..384ad1f
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RelayNetworkStatusVoteImpl.java
@@ -0,0 +1,761 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.DirectorySignature;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.torproject.descriptor.RelayNetworkStatusVote;
+
+/* Contains a network status vote. */
+public class RelayNetworkStatusVoteImpl extends NetworkStatusImpl
+    implements RelayNetworkStatusVote {
+
+  protected static List<RelayNetworkStatusVote> parseVotes(
+      byte[] votesBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<RelayNetworkStatusVote> parsedVotes = new ArrayList<>();
+    List<byte[]> splitVotesBytes =
+        DescriptorImpl.splitRawDescriptorBytes(votesBytes,
+        "network-status-version 3");
+    for (byte[] voteBytes : splitVotesBytes) {
+      RelayNetworkStatusVote parsedVote =
+          new RelayNetworkStatusVoteImpl(voteBytes,
+              failUnrecognizedDescriptorLines);
+      parsedVotes.add(parsedVote);
+    }
+    return parsedVotes;
+  }
+
+  protected RelayNetworkStatusVoteImpl(byte[] voteBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(voteBytes, failUnrecognizedDescriptorLines, false, false);
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
+        "vote-status,published,valid-after,fresh-until,"
+        + "valid-until,voting-delay,known-flags,dir-source,"
+        + "dir-key-certificate-version,fingerprint,dir-key-published,"
+        + "dir-key-expires,dir-identity-key,dir-signing-key,"
+        + "dir-key-certification").split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
+        "consensus-methods,client-versions,server-versions,"
+        + "flag-thresholds,params,contact,"
+        + "legacy-key,dir-key-crosscert,dir-address,directory-footer").
+        split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    Set<String> atLeastOnceKeywords = new HashSet<>(Arrays.asList(
+        "directory-signature"));
+    this.checkAtLeastOnceKeywords(atLeastOnceKeywords);
+    this.checkFirstKeyword("network-status-version");
+    this.clearParsedKeywords();
+  }
+
+  protected void parseHeader(byte[] headerBytes)
+      throws DescriptorParseException {
+    /* Initialize flag-thresholds values here for the case that the vote
+     * doesn't contain those values.  Initializing them in the constructor
+     * or when declaring variables wouldn't work, because those parts are
+     * evaluated later and would overwrite everything we parse here. */
+    this.stableUptime = -1L;
+    this.stableMtbf = -1L;
+    this.fastBandwidth = -1L;
+    this.guardWfu = -1.0;
+    this.guardTk = -1L;
+    this.guardBandwidthIncludingExits = -1L;
+    this.guardBandwidthExcludingExits = -1L;
+    this.enoughMtbfInfo = -1;
+    this.ignoringAdvertisedBws = -1;
+
+    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
+    String nextCrypto = "";
+    StringBuilder crypto = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      String[] parts = line.split("[ \t]+");
+      String keyword = parts[0];
+      switch (keyword) {
+      case "network-status-version":
+        this.parseNetworkStatusVersionLine(line, parts);
+        break;
+      case "vote-status":
+        this.parseVoteStatusLine(line, parts);
+        break;
+      case "consensus-methods":
+        this.parseConsensusMethodsLine(line, parts);
+        break;
+      case "published":
+        this.parsePublishedLine(line, parts);
+        break;
+      case "valid-after":
+        this.parseValidAfterLine(line, parts);
+        break;
+      case "fresh-until":
+        this.parseFreshUntilLine(line, parts);
+        break;
+      case "valid-until":
+        this.parseValidUntilLine(line, parts);
+        break;
+      case "voting-delay":
+        this.parseVotingDelayLine(line, parts);
+        break;
+      case "client-versions":
+        this.parseClientVersionsLine(line, parts);
+        break;
+      case "server-versions":
+        this.parseServerVersionsLine(line, parts);
+        break;
+      case "package":
+        this.parsePackageLine(line, parts);
+        break;
+      case "known-flags":
+        this.parseKnownFlagsLine(line, parts);
+        break;
+      case "flag-thresholds":
+        this.parseFlagThresholdsLine(line, parts);
+        break;
+      case "params":
+        this.parseParamsLine(line, parts);
+        break;
+      case "dir-source":
+        this.parseDirSourceLine(line, parts);
+        break;
+      case "contact":
+        this.parseContactLine(line, parts);
+        break;
+      case "dir-key-certificate-version":
+        this.parseDirKeyCertificateVersionLine(line, parts);
+        break;
+      case "dir-address":
+        this.parseDirAddressLine(line, parts);
+        break;
+      case "fingerprint":
+        this.parseFingerprintLine(line, parts);
+        break;
+      case "legacy-dir-key":
+        this.parseLegacyDirKeyLine(line, parts);
+        break;
+      case "dir-key-published":
+        this.parseDirKeyPublished(line, parts);
+        break;
+      case "dir-key-expires":
+        this.parseDirKeyExpiresLine(line, parts);
+        break;
+      case "dir-identity-key":
+        this.parseDirIdentityKeyLine(line, parts);
+        nextCrypto = "dir-identity-key";
+        break;
+      case "dir-signing-key":
+        this.parseDirSigningKeyLine(line, parts);
+        nextCrypto = "dir-signing-key";
+        break;
+      case "dir-key-crosscert":
+        this.parseDirKeyCrosscertLine(line, parts);
+        nextCrypto = "dir-key-crosscert";
+        break;
+      case "dir-key-certification":
+        this.parseDirKeyCertificationLine(line, parts);
+        nextCrypto = "dir-key-certification";
+        break;
+      case "-----BEGIN":
+        crypto = new StringBuilder();
+        crypto.append(line).append("\n");
+        break;
+      case "-----END":
+        crypto.append(line).append("\n");
+        String cryptoString = crypto.toString();
+        crypto = null;
+        switch (nextCrypto) {
+        case "dir-identity-key":
+          this.dirIdentityKey = cryptoString;
+          break;
+        case "dir-signing-key":
+          this.dirSigningKey = cryptoString;
+          break;
+        case "dir-key-crosscert":
+          this.dirKeyCrosscert = cryptoString;
+          break;
+        case "dir-key-certification":
+          this.dirKeyCertification = cryptoString;
+          break;
+        default:
+          throw new DescriptorParseException("Unrecognized crypto "
+              + "block in vote.");
+        }
+        nextCrypto = "";
+        break;
+      default:
+        if (crypto != null) {
+          crypto.append(line).append("\n");
+        } else {
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in vote.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  private void parseNetworkStatusVersionLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("network-status-version 3")) {
+      throw new DescriptorParseException("Illegal network status version "
+          + "number in line '" + line + "'.");
+    }
+    this.networkStatusVersion = 3;
+  }
+
+  private void parseVoteStatusLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2 || !parts[1].equals("vote")) {
+      throw new DescriptorParseException("Line '" + line + "' indicates "
+          + "that this is not a vote.");
+    }
+  }
+
+  private void parseConsensusMethodsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in vote.");
+    }
+    Integer[] consensusMethods = new Integer[parts.length - 1];
+    for (int i = 1; i < parts.length; i++) {
+      int consensusMethod = -1;
+      try {
+        consensusMethod = Integer.parseInt(parts[i]);
+      } catch (NumberFormatException e) {
+        /* We'll notice below that consensusMethod is still -1. */
+      }
+      if (consensusMethod < 1) {
+        throw new DescriptorParseException("Illegal consensus method "
+            + "number in line '" + line + "'.");
+      }
+      consensusMethods[i - 1] = consensusMethod;
+    }
+    this.consensusMethods = consensusMethods;
+  }
+
+  private void parsePublishedLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseValidAfterLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.validAfterMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseFreshUntilLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.freshUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseValidUntilLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.validUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
+        1, 2);
+  }
+
+  private void parseVotingDelayLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 3) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    try {
+      this.voteSeconds = Long.parseLong(parts[1]);
+      this.distSeconds = Long.parseLong(parts[2]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal values in line '" + line
+          + "'.");
+    }
+  }
+
+  private void parseClientVersionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.recommendedClientVersions = this.parseClientOrServerVersions(
+        line, parts);
+  }
+
+  private void parseServerVersionsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.recommendedServerVersions = this.parseClientOrServerVersions(
+        line, parts);
+  }
+
+  private void parsePackageLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 5) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    if (this.packageLines == null) {
+      this.packageLines = new ArrayList<>();
+    }
+    this.packageLines.add(line.substring("package ".length()));
+  }
+
+  private void parseKnownFlagsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 2) {
+      throw new DescriptorParseException("No known flags in line '" + line
+          + "'.");
+    }
+    String[] knownFlags = new String[parts.length - 1];
+    for (int i = 1; i < parts.length; i++) {
+      knownFlags[i - 1] = parts[i];
+    }
+    this.knownFlags = knownFlags;
+  }
+
+  private void parseFlagThresholdsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 2) {
+      throw new DescriptorParseException("No flag thresholds in line '"
+          + line + "'.");
+    }
+    SortedMap<String, String> flagThresholds =
+        ParseHelper.parseKeyValueStringPairs(line, parts, 1, "=");
+    try {
+      for (Map.Entry<String, String> e : flagThresholds.entrySet()) {
+        switch (e.getKey()) {
+        case "stable-uptime":
+          this.stableUptime = Long.parseLong(e.getValue());
+          break;
+        case "stable-mtbf":
+          this.stableMtbf = Long.parseLong(e.getValue());
+          break;
+        case "fast-speed":
+          this.fastBandwidth = Long.parseLong(e.getValue());
+          break;
+        case "guard-wfu":
+          this.guardWfu = Double.parseDouble(e.getValue().
+              replaceAll("%", ""));
+          break;
+        case "guard-tk":
+          this.guardTk = Long.parseLong(e.getValue());
+          break;
+        case "guard-bw-inc-exits":
+          this.guardBandwidthIncludingExits =
+              Long.parseLong(e.getValue());
+          break;
+        case "guard-bw-exc-exits":
+          this.guardBandwidthExcludingExits =
+              Long.parseLong(e.getValue());
+          break;
+        case "enough-mtbf":
+          this.enoughMtbfInfo = Integer.parseInt(e.getValue());
+          break;
+        case "ignoring-advertised-bws":
+          this.ignoringAdvertisedBws = Integer.parseInt(e.getValue());
+          break;
+        default:
+          // empty
+        }
+      }
+    } catch (NumberFormatException ex) {
+      throw new DescriptorParseException("Illegal value in line '"
+          + line + "'.");
+    }
+  }
+
+  private void parseParamsLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.consensusParams = ParseHelper.parseKeyValueIntegerPairs(line,
+        parts, 1, "=");
+  }
+
+  private void parseDirSourceLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 7) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in vote.");
+    }
+    this.nickname = ParseHelper.parseNickname(line, parts[1]);
+    this.identity = ParseHelper.parseTwentyByteHexString(line, parts[2]);
+    if (parts[3].length() < 1) {
+      throw new DescriptorParseException("Illegal hostname in '" + line
+          + "'.");
+    }
+    this.hostname = parts[3];
+    this.address = ParseHelper.parseIpv4Address(line, parts[4]);
+    this.dirPort = ParseHelper.parsePort(line, parts[5]);
+    this.orPort = ParseHelper.parsePort(line, parts[6]);
+  }
+
+  private void parseContactLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (line.length() > "contact ".length()) {
+      this.contactLine = line.substring("contact ".length());
+    } else {
+      this.contactLine = "";
+    }
+  }
+
+  private void parseDirKeyCertificateVersionLine(String line,
+      String[] parts) throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in vote.");
+    }
+    try {
+      this.dirKeyCertificateVersion = Integer.parseInt(parts[1]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal dir key certificate "
+          + "version in line '" + line + "'.");
+    }
+    if (this.dirKeyCertificateVersion < 1) {
+      throw new DescriptorParseException("Illegal dir key certificate "
+          + "version in line '" + line + "'.");
+    }
+  }
+
+  private void parseDirAddressLine(String line, String[] parts) {
+    /* Nothing new to learn here.  Also, this line hasn't been observed
+     * "in the wild" yet.  Maybe it's just an urban legend. */
+  }
+
+  private void parseFingerprintLine(String line, String[] parts)
+      throws DescriptorParseException {
+    /* Nothing new to learn here.  We already know the fingerprint from
+     * the dir-source line.  But we should at least check that there's a
+     * valid fingerprint in this line. */
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in vote.");
+    }
+    ParseHelper.parseTwentyByteHexString(line, parts[1]);
+  }
+
+  private void parseLegacyDirKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.legacyDirKey = ParseHelper.parseTwentyByteHexString(line, parts[1]);
+  }
+
+  private void parseDirKeyPublished(String line, String[] parts)
+      throws DescriptorParseException {
+    this.dirKeyPublishedMillis = ParseHelper.parseTimestampAtIndex(line,
+        parts, 1, 2);
+  }
+
+  private void parseDirKeyExpiresLine(String line, String[] parts)
+      throws DescriptorParseException {
+    this.dirKeyExpiresMillis = ParseHelper.parseTimestampAtIndex(line,
+        parts, 1, 2);
+  }
+
+  private void parseDirIdentityKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-identity-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseDirSigningKeyLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-signing-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseDirKeyCrosscertLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-key-crosscert")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseDirKeyCertificationLine(String line, String[] parts)
+      throws DescriptorParseException {
+    if (!line.equals("dir-key-certification")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  protected void parseFooter(byte[] footerBytes)
+      throws DescriptorParseException {
+    Scanner s = new Scanner(new String(footerBytes)).useDelimiter("\n");
+    while (s.hasNext()) {
+      String line = s.next();
+      if (!line.equals("directory-footer")) {
+        if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized line '"
+              + line + "' in vote.");
+        } else {
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+        }
+      }
+    }
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String identity;
+  @Override
+  public String getIdentity() {
+    return this.identity;
+  }
+
+  private String hostname;
+  @Override
+  public String getHostname() {
+    return this.hostname;
+  }
+
+  private String address;
+  @Override
+  public String getAddress() {
+    return this.address;
+  }
+
+  private int dirPort;
+  @Override
+  public int getDirport() {
+    return this.dirPort;
+  }
+
+  private int orPort;
+  @Override
+  public int getOrport() {
+    return this.orPort;
+  }
+
+  private String contactLine;
+  @Override
+  public String getContactLine() {
+    return this.contactLine;
+  }
+
+  private int dirKeyCertificateVersion;
+  @Override
+  public int getDirKeyCertificateVersion() {
+    return this.dirKeyCertificateVersion;
+  }
+
+  private String legacyDirKey;
+  @Override
+  public String getLegacyDirKey() {
+    return this.legacyDirKey;
+  }
+
+  private long dirKeyPublishedMillis;
+  @Override
+  public long getDirKeyPublishedMillis() {
+    return this.dirKeyPublishedMillis;
+  }
+
+  private long dirKeyExpiresMillis;
+  @Override
+  public long getDirKeyExpiresMillis() {
+    return this.dirKeyExpiresMillis;
+  }
+
+  private String dirIdentityKey;
+  @Override
+  public String getDirIdentityKey() {
+    return this.dirIdentityKey;
+  }
+
+  private String dirSigningKey;
+  @Override
+  public String getDirSigningKey() {
+    return this.dirSigningKey;
+  }
+
+  private String dirKeyCrosscert;
+  @Override
+  public String getDirKeyCrosscert() {
+    return this.dirKeyCrosscert;
+  }
+
+  private String dirKeyCertification;
+  @Override
+  public String getDirKeyCertification() {
+    return this.dirKeyCertification;
+  }
+
+  @Override
+  public String getSigningKeyDigest() {
+    String signingKeyDigest = null;
+    if (this.signatures != null && !this.signatures.isEmpty()) {
+      for (DirectorySignature signature : this.signatures) {
+        if (DirectorySignatureImpl.DEFAULT_ALGORITHM.equals(
+            signature.getAlgorithm())) {
+          signingKeyDigest = signature.getSigningKeyDigest();
+          break;
+        }
+      }
+    }
+    return signingKeyDigest;
+  }
+
+  private int networkStatusVersion;
+  @Override
+  public int getNetworkStatusVersion() {
+    return this.networkStatusVersion;
+  }
+
+  private Integer[] consensusMethods;
+  @Override
+  public List<Integer> getConsensusMethods() {
+    return this.consensusMethods == null ? null :
+        Arrays.asList(this.consensusMethods);
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private long validAfterMillis;
+  @Override
+  public long getValidAfterMillis() {
+    return this.validAfterMillis;
+  }
+
+  private long freshUntilMillis;
+  @Override
+  public long getFreshUntilMillis() {
+    return this.freshUntilMillis;
+  }
+
+  private long validUntilMillis;
+  @Override
+  public long getValidUntilMillis() {
+    return this.validUntilMillis;
+  }
+
+  private long voteSeconds;
+  @Override
+  public long getVoteSeconds() {
+    return this.voteSeconds;
+  }
+
+  private long distSeconds;
+  @Override
+  public long getDistSeconds() {
+    return this.distSeconds;
+  }
+
+  private String[] recommendedClientVersions;
+  @Override
+  public List<String> getRecommendedClientVersions() {
+    return this.recommendedClientVersions == null ? null :
+        Arrays.asList(this.recommendedClientVersions);
+  }
+
+  private String[] recommendedServerVersions;
+  @Override
+  public List<String> getRecommendedServerVersions() {
+    return this.recommendedServerVersions == null ? null :
+        Arrays.asList(this.recommendedServerVersions);
+  }
+
+  private List<String> packageLines;
+  @Override
+  public List<String> getPackageLines() {
+    return this.packageLines == null ? null
+        : new ArrayList<>(this.packageLines);
+  }
+
+  private String[] knownFlags;
+  @Override
+  public SortedSet<String> getKnownFlags() {
+    return new TreeSet<>(Arrays.asList(this.knownFlags));
+  }
+
+  private long stableUptime;
+  @Override
+  public long getStableUptime() {
+    return this.stableUptime;
+  }
+
+  private long stableMtbf;
+  @Override
+  public long getStableMtbf() {
+    return this.stableMtbf;
+  }
+
+  private long fastBandwidth;
+  @Override
+  public long getFastBandwidth() {
+    return this.fastBandwidth;
+  }
+
+  private double guardWfu;
+  @Override
+  public double getGuardWfu() {
+    return this.guardWfu;
+  }
+
+  private long guardTk;
+  @Override
+  public long getGuardTk() {
+    return this.guardTk;
+  }
+
+  private long guardBandwidthIncludingExits;
+  @Override
+  public long getGuardBandwidthIncludingExits() {
+    return this.guardBandwidthIncludingExits;
+  }
+
+  private long guardBandwidthExcludingExits;
+  @Override
+  public long getGuardBandwidthExcludingExits() {
+    return this.guardBandwidthExcludingExits;
+  }
+
+  private int enoughMtbfInfo;
+  @Override
+  public int getEnoughMtbfInfo() {
+    return this.enoughMtbfInfo;
+  }
+
+  private int ignoringAdvertisedBws;
+  @Override
+  public int getIgnoringAdvertisedBws() {
+    return this.ignoringAdvertisedBws;
+  }
+
+  private SortedMap<String, Integer> consensusParams;
+  @Override
+  public SortedMap<String, Integer> getConsensusParams() {
+    return this.consensusParams == null ? null:
+        new TreeMap<>(this.consensusParams);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RelayServerDescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/RelayServerDescriptorImpl.java
new file mode 100644
index 0000000..4957072
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RelayServerDescriptorImpl.java
@@ -0,0 +1,37 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.RelayServerDescriptor;
+import org.torproject.descriptor.ServerDescriptor;
+
+public class RelayServerDescriptorImpl extends ServerDescriptorImpl
+    implements RelayServerDescriptor {
+
+  protected static List<ServerDescriptor> parseDescriptors(
+      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    List<ServerDescriptor> parsedDescriptors = new ArrayList<>();
+    List<byte[]> splitDescriptorsBytes =
+        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
+        "router ");
+    for (byte[] descriptorBytes : splitDescriptorsBytes) {
+      ServerDescriptor parsedDescriptor =
+          new RelayServerDescriptorImpl(descriptorBytes,
+          failUnrecognizedDescriptorLines);
+      parsedDescriptors.add(parsedDescriptor);
+    }
+    return parsedDescriptors;
+  }
+
+  protected RelayServerDescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/RouterStatusEntryImpl.java b/src/main/java/org/torproject/descriptor/impl/RouterStatusEntryImpl.java
new file mode 100644
index 0000000..a359c50
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/RouterStatusEntryImpl.java
@@ -0,0 +1,41 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.RouterStatusEntry;
+
+public class RouterStatusEntryImpl implements RouterStatusEntry {
+
+  protected RouterStatusEntryImpl(String fingerprint, String nickname,
+      boolean isLive, boolean isVerified) {
+    this.fingerprint = fingerprint;
+    this.nickname = nickname;
+    this.isLive = isLive;
+    this.isVerified = isVerified;
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private boolean isLive;
+  @Override
+  public boolean isLive() {
+    return this.isLive;
+  }
+
+  private boolean isVerified;
+  @Override
+  public boolean isVerified() {
+    return this.isVerified;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/ServerDescriptorImpl.java b/src/main/java/org/torproject/descriptor/impl/ServerDescriptorImpl.java
new file mode 100644
index 0000000..1805dca
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/ServerDescriptorImpl.java
@@ -0,0 +1,985 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+
+import javax.xml.bind.DatatypeConverter;
+
+import org.torproject.descriptor.BandwidthHistory;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ServerDescriptor;
+
+/* Contains a server descriptor. */
+public abstract class ServerDescriptorImpl extends DescriptorImpl
+    implements ServerDescriptor {
+
+  protected ServerDescriptorImpl(byte[] descriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.parseDescriptorBytes();
+    this.calculateDigest();
+    this.calculateDigestSha256();
+    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList(
+        "router,bandwidth,published".split(",")));
+    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
+    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
+        "identity-ed25519,master-key-ed25519,platform,fingerprint,"
+        + "hibernating,uptime,contact,family,read-history,write-history,"
+        + "eventdns,caches-extra-info,extra-info-digest,"
+        + "hidden-service-dir,protocols,allow-single-hop-exits,onion-key,"
+        + "signing-key,ipv6-policy,ntor-onion-key,onion-key-crosscert,"
+        + "ntor-onion-key-crosscert,tunnelled-dir-server,"
+        + "router-sig-ed25519,router-signature,router-digest-sha256,"
+        + "router-digest").split(",")));
+    this.checkAtMostOnceKeywords(atMostOnceKeywords);
+    this.checkFirstKeyword("router");
+    if (this.getKeywordCount("accept") == 0 &&
+        this.getKeywordCount("reject") == 0) {
+      throw new DescriptorParseException("Either keyword 'accept' or "
+          + "'reject' must be contained at least once.");
+    }
+    this.clearParsedKeywords();
+    return;
+  }
+
+  private void parseDescriptorBytes() throws DescriptorParseException {
+    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
+        useDelimiter("\n");
+    String nextCrypto = "";
+    List<String> cryptoLines = null;
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.startsWith("@")) {
+        continue;
+      }
+      String lineNoOpt = line.startsWith("opt ") ?
+          line.substring("opt ".length()) : line;
+      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
+      String keyword = partsNoOpt[0];
+      switch (keyword) {
+      case "router":
+        this.parseRouterLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "or-address":
+        this.parseOrAddressLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "bandwidth":
+        this.parseBandwidthLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "platform":
+        this.parsePlatformLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "published":
+        this.parsePublishedLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "fingerprint":
+        this.parseFingerprintLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "hibernating":
+        this.parseHibernatingLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "uptime":
+        this.parseUptimeLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "onion-key":
+        this.parseOnionKeyLine(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "onion-key";
+        break;
+      case "signing-key":
+        this.parseSigningKeyLine(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "signing-key";
+        break;
+      case "accept":
+        this.parseAcceptLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "reject":
+        this.parseRejectLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-signature":
+        this.parseRouterSignatureLine(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "router-signature";
+        break;
+      case "contact":
+        this.parseContactLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "family":
+        this.parseFamilyLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "read-history":
+        this.parseReadHistoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "write-history":
+        this.parseWriteHistoryLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "eventdns":
+        this.parseEventdnsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "caches-extra-info":
+        this.parseCachesExtraInfoLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "extra-info-digest":
+        this.parseExtraInfoDigestLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "hidden-service-dir":
+        this.parseHiddenServiceDirLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "protocols":
+        this.parseProtocolsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "allow-single-hop-exits":
+        this.parseAllowSingleHopExitsLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "dircacheport":
+        this.parseDircacheportLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-digest":
+        this.parseRouterDigestLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-digest-sha256":
+        this.parseRouterDigestSha256Line(line, lineNoOpt, partsNoOpt);
+        break;
+      case "ipv6-policy":
+        this.parseIpv6PolicyLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "ntor-onion-key":
+        this.parseNtorOnionKeyLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "identity-ed25519":
+        this.parseIdentityEd25519Line(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "identity-ed25519";
+        break;
+      case "master-key-ed25519":
+        this.parseMasterKeyEd25519Line(line, lineNoOpt, partsNoOpt);
+        break;
+      case "router-sig-ed25519":
+        this.parseRouterSigEd25519Line(line, lineNoOpt, partsNoOpt);
+        break;
+      case "onion-key-crosscert":
+        this.parseOnionKeyCrosscert(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "onion-key-crosscert";
+        break;
+      case "ntor-onion-key-crosscert":
+        this.parseNtorOnionKeyCrosscert(line, lineNoOpt, partsNoOpt);
+        nextCrypto = "ntor-onion-key-crosscert";
+        break;
+      case "tunnelled-dir-server":
+        this.parseTunnelledDirServerLine(line, lineNoOpt, partsNoOpt);
+        break;
+      case "-----BEGIN":
+        cryptoLines = new ArrayList<>();
+        cryptoLines.add(line);
+        break;
+      case "-----END":
+        cryptoLines.add(line);
+        StringBuilder sb = new StringBuilder();
+        for (String cryptoLine : cryptoLines) {
+          sb.append("\n").append(cryptoLine);
+        }
+        String cryptoString = sb.toString().substring(1);
+        switch (nextCrypto) {
+        case "onion-key":
+          this.onionKey = cryptoString;
+          break;
+        case "signing-key":
+          this.signingKey = cryptoString;
+          break;
+        case "router-signature":
+          this.routerSignature = cryptoString;
+          break;
+        case "identity-ed25519":
+          this.identityEd25519 = cryptoString;
+          this.parseIdentityEd25519CryptoBlock(cryptoString);
+          break;
+        case "onion-key-crosscert":
+          this.onionKeyCrosscert = cryptoString;
+          break;
+        case "ntor-onion-key-crosscert":
+          this.ntorOnionKeyCrosscert = cryptoString;
+          break;
+        default:
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized crypto "
+                + "block '" + cryptoString + "' in server descriptor.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.addAll(cryptoLines);
+          }
+        }
+        cryptoLines = null;
+        nextCrypto = "";
+        break;
+      default:
+        if (cryptoLines != null) {
+          cryptoLines.add(line);
+        } else {
+          ParseHelper.parseKeyword(line, partsNoOpt[0]);
+          if (this.failUnrecognizedDescriptorLines) {
+            throw new DescriptorParseException("Unrecognized line '"
+                + line + "' in server descriptor.");
+          } else {
+            if (this.unrecognizedLines == null) {
+              this.unrecognizedLines = new ArrayList<>();
+            }
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+  }
+
+  private void parseRouterLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 6) {
+      throw new DescriptorParseException("Illegal line '" + line
+          + "' in server descriptor.");
+    }
+    this.nickname = ParseHelper.parseNickname(line, partsNoOpt[1]);
+    this.address = ParseHelper.parseIpv4Address(line, partsNoOpt[2]);
+    this.orPort = ParseHelper.parsePort(line, partsNoOpt[3]);
+    this.socksPort = ParseHelper.parsePort(line, partsNoOpt[4]);
+    this.dirPort = ParseHelper.parsePort(line, partsNoOpt[5]);
+  }
+
+  private void parseOrAddressLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    /* TODO Add more checks. */
+    /* TODO Add tests. */
+    this.orAddresses.add(partsNoOpt[1]);
+  }
+
+  private void parseBandwidthLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length < 3 || partsNoOpt.length > 4) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    boolean isValid = false;
+    try {
+      this.bandwidthRate = Integer.parseInt(partsNoOpt[1]);
+      this.bandwidthBurst = Integer.parseInt(partsNoOpt[2]);
+      if (partsNoOpt.length == 4) {
+        this.bandwidthObserved = Integer.parseInt(partsNoOpt[3]);
+      }
+      if (this.bandwidthRate >= 0 && this.bandwidthBurst >= 0 &&
+          this.bandwidthObserved >= 0) {
+        isValid = true;
+      }
+      if (partsNoOpt.length < 4) {
+        /* Tor versions 0.0.8 and older only wrote bandwidth lines with
+         * rate and burst values, but no observed value. */
+        this.bandwidthObserved = -1;
+      }
+    } catch (NumberFormatException e) {
+      /* Handle below. */
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("Illegal values in line '" + line
+          + "'.");
+    }
+  }
+
+  private void parsePlatformLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (lineNoOpt.length() > "platform ".length()) {
+      this.platform = lineNoOpt.substring("platform ".length());
+    } else {
+      this.platform = "";
+    }
+  }
+
+  private void parsePublishedLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
+        partsNoOpt, 1, 2);
+  }
+
+  private void parseFingerprintLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (lineNoOpt.length() != "fingerprint".length() + 5 * 10) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
+        lineNoOpt.substring("fingerprint ".length()).replaceAll(" ", ""));
+  }
+
+  private void parseHibernatingLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.hibernating = ParseHelper.parseBoolean(partsNoOpt[1], line);
+  }
+
+  private void parseUptimeLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Wrong number of values in line "
+          + "'" + line + "'.");
+    }
+    boolean isValid = false;
+    try {
+      this.uptime = Long.parseLong(partsNoOpt[1]);
+      isValid = true;
+    } catch (NumberFormatException e) {
+      /* Handle below. */
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("Illegal value in line '" + line
+          + "'.");
+    }
+  }
+
+  private void parseOnionKeyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("onion-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseSigningKeyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("signing-key")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseAcceptLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.parseExitPolicyLine(line, lineNoOpt, partsNoOpt);
+  }
+
+  private void parseRejectLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.parseExitPolicyLine(line, lineNoOpt, partsNoOpt);
+  }
+
+  private void parseExitPolicyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    ParseHelper.parseExitPattern(line, partsNoOpt[1]);
+    this.exitPolicyLines.add(lineNoOpt);
+  }
+
+  private void parseRouterSignatureLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("router-signature")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseContactLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (lineNoOpt.length() > "contact ".length()) {
+      this.contact = lineNoOpt.substring("contact ".length());
+    } else {
+      this.contact = "";
+    }
+  }
+
+  private void parseFamilyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    String[] familyEntries = new String[partsNoOpt.length - 1];
+    for (int i = 1; i < partsNoOpt.length; i++) {
+      if (partsNoOpt[i].startsWith("$")) {
+        if (partsNoOpt[i].contains("=") ^ partsNoOpt[i].contains("~")) {
+          String separator = partsNoOpt[i].contains("=") ? "=" : "~";
+          String fingerprint = ParseHelper.parseTwentyByteHexString(line,
+              partsNoOpt[i].substring(1, partsNoOpt[i].indexOf(
+              separator)));
+          String nickname = ParseHelper.parseNickname(line,
+              partsNoOpt[i].substring(partsNoOpt[i].indexOf(
+              separator) + 1));
+          familyEntries[i - 1] = "$" + fingerprint + separator + nickname;
+        } else {
+          familyEntries[i - 1] = "$"
+              + ParseHelper.parseTwentyByteHexString(line,
+              partsNoOpt[i].substring(1));
+        }
+      } else {
+        familyEntries[i - 1] = ParseHelper.parseNickname(line,
+            partsNoOpt[i]);
+      }
+    }
+    this.familyEntries = familyEntries;
+  }
+
+  private void parseReadHistoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.readHistory = new BandwidthHistoryImpl(line, lineNoOpt,
+        partsNoOpt);
+  }
+
+  private void parseWriteHistoryLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    this.writeHistory = new BandwidthHistoryImpl(line, lineNoOpt,
+        partsNoOpt);
+  }
+
+  private void parseEventdnsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.usesEnhancedDnsLogic = ParseHelper.parseBoolean(partsNoOpt[1], line);
+  }
+
+  private void parseCachesExtraInfoLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("caches-extra-info")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.cachesExtraInfo = true;
+  }
+
+  private void parseExtraInfoDigestLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length < 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.extraInfoDigest = ParseHelper.parseTwentyByteHexString(line,
+        partsNoOpt[1]);
+    if (partsNoOpt.length >= 3) {
+      ParseHelper.parseThirtyTwoByteBase64String(line, partsNoOpt[2]);
+      this.extraInfoDigestSha256 = partsNoOpt[2];
+    }
+  }
+
+  private void parseHiddenServiceDirLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length == 1) {
+      this.hiddenServiceDirVersions = new Integer[] { 2 };
+    } else {
+      try {
+        Integer[] result = new Integer[partsNoOpt.length - 1];
+        for (int i = 1; i < partsNoOpt.length; i++) {
+          result[i - 1] = Integer.parseInt(partsNoOpt[i]);
+        }
+        this.hiddenServiceDirVersions = result;
+      } catch (NumberFormatException e) {
+        throw new DescriptorParseException("Illegal value in line '"
+            + line + "'.");
+      }
+    }
+  }
+
+  private void parseProtocolsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    int linkIndex = -1, circuitIndex = -1;
+    for (int i = 1; i < partsNoOpt.length; i++) {
+      switch (partsNoOpt[i]) {
+      case "Link":
+        linkIndex = i;
+        break;
+      case "Circuit":
+        circuitIndex = i;
+        break;
+      default:
+        // empty
+      }
+    }
+    if (linkIndex < 0 || circuitIndex < 0 || circuitIndex < linkIndex) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    try {
+      Integer[] linkProtocolVersions =
+          new Integer[circuitIndex - linkIndex - 1];
+      for (int i = linkIndex + 1, j = 0; i < circuitIndex; i++, j++) {
+        linkProtocolVersions[j] = Integer.parseInt(partsNoOpt[i]);
+      }
+      Integer[] circuitProtocolVersions =
+          new Integer[partsNoOpt.length - circuitIndex - 1];
+      for (int i = circuitIndex + 1, j = 0; i < partsNoOpt.length;
+          i++, j++) {
+        circuitProtocolVersions[j] = Integer.parseInt(partsNoOpt[i]);
+      }
+      this.linkProtocolVersions = linkProtocolVersions;
+      this.circuitProtocolVersions = circuitProtocolVersions;
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseAllowSingleHopExitsLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("allow-single-hop-exits")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.allowSingleHopExits = true;
+  }
+
+  private void parseDircacheportLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    /* The dircacheport line was only contained in server descriptors
+     * published by Tor 0.0.8 and before.  It's only specified in old
+     * tor-spec.txt versions. */
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    if (this.dirPort != 0) {
+      throw new DescriptorParseException("At most one of dircacheport "
+          + "and the directory port in the router line may be non-zero.");
+    }
+    this.dirPort = ParseHelper.parsePort(line, partsNoOpt[1]);
+  }
+
+  private void parseRouterDigestLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.serverDescriptorDigest = ParseHelper.parseTwentyByteHexString(
+        line, partsNoOpt[1]);
+  }
+
+  private void parseIpv6PolicyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    boolean isValid = true;
+    if (partsNoOpt.length != 3) {
+      isValid = false;
+    } else {
+        switch (partsNoOpt[1]) {
+        case "accept":
+        case "reject":
+          this.ipv6DefaultPolicy = partsNoOpt[1];
+          this.ipv6PortList = partsNoOpt[2];
+          String[] ports = partsNoOpt[2].split(",", -1);
+          for (int i = 0; i < ports.length; i++) {
+            if (ports[i].length() < 1) {
+              isValid = false;
+              break;
+            }
+          }
+          break;
+        default:
+          isValid = false;
+        }
+    }
+    if (!isValid) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseNtorOnionKeyLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.ntorOnionKey = partsNoOpt[1].replaceAll("=", "");
+  }
+
+  private void parseIdentityEd25519Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 1) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseOnionKeyCrosscert(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 1) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseNtorOnionKeyCrosscert(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    try {
+      this.ntorOnionKeyCrosscertSign = Integer.parseInt(partsNoOpt[1]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+  }
+
+  private void parseTunnelledDirServerLine(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (!lineNoOpt.equals("tunnelled-dir-server")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.tunnelledDirServer = true;
+  }
+
+  private void parseIdentityEd25519CryptoBlock(String cryptoString)
+      throws DescriptorParseException {
+    String masterKeyEd25519FromIdentityEd25519 =
+        ParseHelper.parseMasterKeyEd25519FromIdentityEd25519CryptoBlock(
+        cryptoString);
+    if (this.masterKeyEd25519 != null && !this.masterKeyEd25519.equals(
+        masterKeyEd25519FromIdentityEd25519)) {
+      throw new DescriptorParseException("Mismatch between "
+          + "identity-ed25519 and master-key-ed25519.");
+    }
+    this.masterKeyEd25519 = masterKeyEd25519FromIdentityEd25519;
+  }
+
+  private void parseMasterKeyEd25519Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    String masterKeyEd25519FromMasterKeyEd25519Line = partsNoOpt[1];
+    if (this.masterKeyEd25519 != null && !masterKeyEd25519.equals(
+        masterKeyEd25519FromMasterKeyEd25519Line)) {
+      throw new DescriptorParseException("Mismatch between "
+          + "identity-ed25519 and master-key-ed25519.");
+    }
+    this.masterKeyEd25519 = masterKeyEd25519FromMasterKeyEd25519Line;
+  }
+
+  private void parseRouterSigEd25519Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.routerSignatureEd25519 = partsNoOpt[1];
+  }
+
+  private void parseRouterDigestSha256Line(String line, String lineNoOpt,
+      String[] partsNoOpt) throws DescriptorParseException {
+    if (partsNoOpt.length != 2) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    ParseHelper.parseThirtyTwoByteBase64String(line, partsNoOpt[1]);
+    this.serverDescriptorDigestSha256 = partsNoOpt[1];
+  }
+
+  private void calculateDigest() throws DescriptorParseException {
+    if (this.serverDescriptorDigest != null) {
+      /* We already learned the descriptor digest of this bridge
+       * descriptor from a "router-digest" line. */
+      return;
+    }
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "router ";
+      String sigToken = "\nrouter-signature\n";
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start,
+            forDigest, 0, sig - start);
+        this.serverDescriptorDigest = DatatypeConverter.printHexBinary(
+            MessageDigest.getInstance("SHA-1").digest(forDigest)).
+            toLowerCase();
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.serverDescriptorDigest == null) {
+      throw new DescriptorParseException("Could not calculate server "
+          + "descriptor digest.");
+    }
+  }
+
+  private void calculateDigestSha256() throws DescriptorParseException {
+    if (this.serverDescriptorDigestSha256 != null) {
+      /* We already learned the descriptor digest of this bridge
+       * descriptor from a "router-digest-sha256" line. */
+      return;
+    }
+    try {
+      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
+      String startToken = "router ";
+      String sigToken = "\n-----END SIGNATURE-----\n";
+      int start = ascii.indexOf(startToken);
+      int sig = ascii.indexOf(sigToken) + sigToken.length();
+      if (start >= 0 && sig >= 0 && sig > start) {
+        byte[] forDigest = new byte[sig - start];
+        System.arraycopy(this.getRawDescriptorBytes(), start, forDigest,
+            0, sig - start);
+        this.serverDescriptorDigestSha256 =
+            DatatypeConverter.printBase64Binary(
+            MessageDigest.getInstance("SHA-256").digest(forDigest)).
+            replaceAll("=", "");
+      }
+    } catch (UnsupportedEncodingException e) {
+      /* Handle below. */
+    } catch (NoSuchAlgorithmException e) {
+      /* Handle below. */
+    }
+    if (this.serverDescriptorDigestSha256 == null) {
+      throw new DescriptorParseException("Could not calculate server "
+          + "descriptor SHA-256 digest.");
+    }
+  }
+
+  private String serverDescriptorDigest;
+  @Override
+  public String getServerDescriptorDigest() {
+    return this.serverDescriptorDigest;
+  }
+
+  private String serverDescriptorDigestSha256;
+  @Override
+  public String getServerDescriptorDigestSha256() {
+    return this.serverDescriptorDigestSha256;
+  }
+
+  private String nickname;
+  @Override
+  public String getNickname() {
+    return this.nickname;
+  }
+
+  private String address;
+  @Override
+  public String getAddress() {
+    return this.address;
+  }
+
+  private int orPort;
+  @Override
+  public int getOrPort() {
+    return this.orPort;
+  }
+
+  private int socksPort;
+  @Override
+  public int getSocksPort() {
+    return this.socksPort;
+  }
+
+  private int dirPort;
+  @Override
+  public int getDirPort() {
+    return this.dirPort;
+  }
+
+  private List<String> orAddresses = new ArrayList<>();
+  @Override
+  public List<String> getOrAddresses() {
+    return new ArrayList<>(this.orAddresses);
+  }
+
+  private int bandwidthRate;
+  @Override
+  public int getBandwidthRate() {
+    return this.bandwidthRate;
+  }
+
+  private int bandwidthBurst;
+  @Override
+  public int getBandwidthBurst() {
+    return this.bandwidthBurst;
+  }
+
+  private int bandwidthObserved;
+  @Override
+  public int getBandwidthObserved() {
+    return this.bandwidthObserved;
+  }
+
+  private String platform;
+  @Override
+  public String getPlatform() {
+    return this.platform;
+  }
+
+  private long publishedMillis;
+  @Override
+  public long getPublishedMillis() {
+    return this.publishedMillis;
+  }
+
+  private String fingerprint;
+  @Override
+  public String getFingerprint() {
+    return this.fingerprint;
+  }
+
+  private boolean hibernating;
+  @Override
+  public boolean isHibernating() {
+    return this.hibernating;
+  }
+
+  private Long uptime;
+  @Override
+  public Long getUptime() {
+    return this.uptime;
+  }
+
+  private String onionKey;
+  @Override
+  public String getOnionKey() {
+    return this.onionKey;
+  }
+
+  private String signingKey;
+  @Override
+  public String getSigningKey() {
+    return this.signingKey;
+  }
+
+  private List<String> exitPolicyLines = new ArrayList<>();
+  @Override
+  public List<String> getExitPolicyLines() {
+    return new ArrayList<>(this.exitPolicyLines);
+  }
+
+  private String routerSignature;
+  @Override
+  public String getRouterSignature() {
+    return this.routerSignature;
+  }
+
+  private String contact;
+  @Override
+  public String getContact() {
+    return this.contact;
+  }
+
+  private String[] familyEntries;
+  @Override
+  public List<String> getFamilyEntries() {
+    return this.familyEntries == null ? null :
+        Arrays.asList(this.familyEntries);
+  }
+
+  private BandwidthHistory readHistory;
+  @Override
+  public BandwidthHistory getReadHistory() {
+    return this.readHistory;
+  }
+
+  private BandwidthHistory writeHistory;
+  @Override
+  public BandwidthHistory getWriteHistory() {
+    return this.writeHistory;
+  }
+
+  private boolean usesEnhancedDnsLogic;
+  @Override
+  public boolean getUsesEnhancedDnsLogic() {
+    return this.usesEnhancedDnsLogic;
+  }
+
+  private boolean cachesExtraInfo;
+  @Override
+  public boolean getCachesExtraInfo() {
+    return this.cachesExtraInfo;
+  }
+
+  private String extraInfoDigest;
+  @Override
+  public String getExtraInfoDigest() {
+    return this.extraInfoDigest;
+  }
+
+  private String extraInfoDigestSha256;
+  @Override
+  public String getExtraInfoDigestSha256() {
+    return this.extraInfoDigestSha256;
+  }
+
+  private Integer[] hiddenServiceDirVersions;
+  @Override
+  public List<Integer> getHiddenServiceDirVersions() {
+    return this.hiddenServiceDirVersions == null ? null :
+        Arrays.asList(this.hiddenServiceDirVersions);
+  }
+
+  private Integer[] linkProtocolVersions;
+  @Override
+  public List<Integer> getLinkProtocolVersions() {
+    return this.linkProtocolVersions == null ? null :
+        Arrays.asList(this.linkProtocolVersions);
+  }
+
+  private Integer[] circuitProtocolVersions;
+  @Override
+  public List<Integer> getCircuitProtocolVersions() {
+    return this.circuitProtocolVersions == null ? null :
+        Arrays.asList(this.circuitProtocolVersions);
+  }
+
+  private boolean allowSingleHopExits;
+  @Override
+  public boolean getAllowSingleHopExits() {
+    return this.allowSingleHopExits;
+  }
+
+  private String ipv6DefaultPolicy;
+  @Override
+  public String getIpv6DefaultPolicy() {
+    return this.ipv6DefaultPolicy;
+  }
+
+  private String ipv6PortList;
+  @Override
+  public String getIpv6PortList() {
+    return this.ipv6PortList;
+  }
+
+  private String ntorOnionKey;
+  @Override
+  public String getNtorOnionKey() {
+    return this.ntorOnionKey;
+  }
+
+  private String identityEd25519;
+  @Override
+  public String getIdentityEd25519() {
+    return this.identityEd25519;
+  }
+
+  private String masterKeyEd25519;
+  @Override
+  public String getMasterKeyEd25519() {
+    return this.masterKeyEd25519;
+  }
+
+  private String routerSignatureEd25519;
+  @Override
+  public String getRouterSignatureEd25519() {
+    return this.routerSignatureEd25519;
+  }
+
+  private String onionKeyCrosscert;
+  @Override
+  public String getOnionKeyCrosscert() {
+    return this.onionKeyCrosscert;
+  }
+
+  private String ntorOnionKeyCrosscert;
+  @Override
+  public String getNtorOnionKeyCrosscert() {
+    return this.ntorOnionKeyCrosscert;
+  }
+
+  private int ntorOnionKeyCrosscertSign = -1;
+  @Override
+  public int getNtorOnionKeyCrosscertSign() {
+    return ntorOnionKeyCrosscertSign;
+  }
+
+  private boolean tunnelledDirServer;
+  @Override
+  public boolean getTunnelledDirServer() {
+    return this.tunnelledDirServer;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java b/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java
new file mode 100644
index 0000000..0800de0
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java
@@ -0,0 +1,546 @@
+/* Copyright 2012--2016 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.TorperfResult;
+
+public class TorperfResultImpl extends DescriptorImpl
+    implements TorperfResult {
+
+  protected static List<Descriptor> parseTorperfResults(
+      byte[] rawDescriptorBytes, boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    if (rawDescriptorBytes.length == 0) {
+      throw new DescriptorParseException("Descriptor is empty.");
+    }
+    List<Descriptor> parsedDescriptors = new ArrayList<>();
+    String descriptorString = new String(rawDescriptorBytes);
+    Scanner s = new Scanner(descriptorString).useDelimiter("\r?\n");
+    String typeAnnotation = "";
+    while (s.hasNext()) {
+      String line = s.next();
+      if (line.startsWith("@type torperf ")) {
+        String[] parts = line.split(" ");
+        if (parts.length != 3) {
+          throw new DescriptorParseException("Illegal line '" + line
+              + "'.");
+        }
+        String version = parts[2];
+        if (!version.startsWith("1.")) {
+          throw new DescriptorParseException("Unsupported version in "
+              + " line '" + line + "'.");
+        }
+        typeAnnotation = line + "\n";
+      } else {
+        parsedDescriptors.add(new TorperfResultImpl(
+            (typeAnnotation + line).getBytes(),
+            failUnrecognizedDescriptorLines));
+        typeAnnotation = "";
+      }
+    }
+    return parsedDescriptors;
+  }
+
+  protected TorperfResultImpl(byte[] rawDescriptorBytes,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    super(rawDescriptorBytes, failUnrecognizedDescriptorLines, false);
+    this.parseTorperfResultLine(new String(rawDescriptorBytes));
+  }
+
+  private void parseTorperfResultLine(String inputLine)
+      throws DescriptorParseException {
+    String line = inputLine;
+    while (line.startsWith("@") && line.contains("\n")) {
+      line = line.split("\n")[1];
+    }
+    if (line.isEmpty()) {
+      throw new DescriptorParseException("Blank lines are not allowed.");
+    }
+    String[] parts = line.split(" ");
+    for (int i = 0; i < parts.length; i++) {
+      String keyValue = parts[i];
+      String[] keyValueParts = keyValue.split("=");
+      if (keyValueParts.length != 2) {
+        throw new DescriptorParseException("Illegal key-value pair in "
+            + "line '" + line + "'.");
+      }
+      String key = keyValueParts[0];
+      this.markKeyAsParsed(key, line);
+      String value = keyValueParts[1];
+      switch (key) {
+      case "SOURCE":
+        this.parseSource(value, keyValue, line);
+        break;
+      case "FILESIZE":
+        this.parseFileSize(value, keyValue, line);
+        break;
+      case "START":
+        this.parseStart(value, keyValue, line);
+        break;
+      case "SOCKET":
+        this.parseSocket(value, keyValue, line);
+        break;
+      case "CONNECT":
+        this.parseConnect(value, keyValue, line);
+        break;
+      case "NEGOTIATE":
+        this.parseNegotiate(value, keyValue, line);
+        break;
+      case "REQUEST":
+        this.parseRequest(value, keyValue, line);
+        break;
+      case "RESPONSE":
+        this.parseResponse(value, keyValue, line);
+        break;
+      case "DATAREQUEST":
+        this.parseDataRequest(value, keyValue, line);
+        break;
+      case "DATARESPONSE":
+        this.parseDataResponse(value, keyValue, line);
+        break;
+      case "DATACOMPLETE":
+        this.parseDataComplete(value, keyValue, line);
+        break;
+      case "WRITEBYTES":
+        this.parseWriteBytes(value, keyValue, line);
+        break;
+      case "READBYTES":
+        this.parseReadBytes(value, keyValue, line);
+        break;
+      case "DIDTIMEOUT":
+        this.parseDidTimeout(value, keyValue, line);
+        break;
+      case "LAUNCH":
+        this.parseLaunch(value, keyValue, line);
+        break;
+      case "USED_AT":
+        this.parseUsedAt(value, keyValue, line);
+        break;
+      case "PATH":
+        this.parsePath(value, keyValue, line);
+        break;
+      case "BUILDTIMES":
+        this.parseBuildTimes(value, keyValue, line);
+        break;
+      case "TIMEOUT":
+        this.parseTimeout(value, keyValue, line);
+        break;
+      case "QUANTILE":
+        this.parseQuantile(value, keyValue, line);
+        break;
+      case "CIRC_ID":
+        this.parseCircId(value, keyValue, line);
+        break;
+      case "USED_BY":
+        this.parseUsedBy(value, keyValue, line);
+        break;
+      default:
+        if (key.startsWith("DATAPERC")) {
+          this.parseDataPercentile(value, keyValue, line);
+        } else if (this.failUnrecognizedDescriptorLines) {
+          throw new DescriptorParseException("Unrecognized key '" + key
+              + "' in line '" + line + "'.");
+        } else {
+          if (this.unrecognizedKeys == null) {
+            this.unrecognizedKeys = new TreeMap<>();
+          }
+          this.unrecognizedKeys.put(key, value);
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          if (!this.unrecognizedLines.contains(line)) {
+            this.unrecognizedLines.add(line);
+          }
+        }
+      }
+    }
+    this.checkAllRequiredKeysParsed(line);
+  }
+
+  private Set<String> parsedKeys = new HashSet<>();
+  private Set<String> requiredKeys = new HashSet<>(Arrays.asList(
+      ("SOURCE,FILESIZE,START,SOCKET,CONNECT,NEGOTIATE,REQUEST,RESPONSE,"
+      + "DATAREQUEST,DATARESPONSE,DATACOMPLETE,WRITEBYTES,READBYTES").
+      split(",")));
+  private void markKeyAsParsed(String key, String line)
+      throws DescriptorParseException {
+    if (this.parsedKeys.contains(key)) {
+      throw new DescriptorParseException("Key '" + key + "' is contained "
+          + "at least twice in line '" + line + "', but must be "
+          + "contained at most once.");
+    }
+    this.parsedKeys.add(key);
+    this.requiredKeys.remove(key);
+  }
+  private void checkAllRequiredKeysParsed(String line)
+      throws DescriptorParseException {
+    for (String key : this.requiredKeys) {
+      throw new DescriptorParseException("Key '" + key + "' is contained "
+          + "contained 0 times in line '" + line + "', but must be "
+          + "contained exactly once.");
+    }
+  }
+
+  private void parseSource(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.source = value;
+  }
+
+  private void parseFileSize(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    try {
+      this.fileSize = Integer.parseInt(value);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal value in '" + keyValue
+          + "' in line '" + line + "'.");
+    }
+  }
+
+  private void parseStart(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.startMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseSocket(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.socketMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseConnect(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.connectMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseNegotiate(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.negotiateMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseRequest(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.requestMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseResponse(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.responseMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseDataRequest(String value, String keyValue,
+      String line) throws DescriptorParseException {
+    this.dataRequestMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseDataResponse(String value, String keyValue,
+      String line) throws DescriptorParseException {
+    this.dataResponseMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseDataComplete(String value, String keyValue,
+      String line) throws DescriptorParseException {
+    this.dataCompleteMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseWriteBytes(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.writeBytes = parseInt(value, keyValue, line);
+  }
+
+  private void parseReadBytes(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.readBytes = parseInt(value, keyValue, line);
+  }
+
+  private void parseDidTimeout(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    if (value.equals("1")) {
+      this.didTimeout = true;
+    } else if (value.equals("0")) {
+      this.didTimeout = false;
+    } else {
+      throw new DescriptorParseException("Illegal value in '" + keyValue
+          + "' in line '" + line + "'.");
+    }
+  }
+
+  private void parseDataPercentile(String value, String keyValue,
+      String line) throws DescriptorParseException {
+    String key = keyValue.substring(0, keyValue.indexOf("="));
+    String percentileString = key.substring("DATAPERC".length());
+    int percentile = -1;
+    try {
+      percentile = Integer.parseInt(percentileString);
+    } catch (NumberFormatException e) {
+      /* Treat key as unrecognized below. */
+      percentile = -1;
+    }
+    if (percentile < 0 || percentile > 100) {
+      if (this.unrecognizedKeys == null) {
+        this.unrecognizedKeys = new TreeMap<>();
+      }
+      this.unrecognizedKeys.put(key, value);
+    } else {
+      long timestamp = this.parseTimestamp(value, keyValue, line);
+      if (this.dataPercentiles == null) {
+        this.dataPercentiles = new TreeMap<>();
+      }
+      this.dataPercentiles.put(percentile, timestamp);
+    }
+  }
+
+  private void parseLaunch(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.launchMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parseUsedAt(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.usedAtMillis = this.parseTimestamp(value, keyValue, line);
+  }
+
+  private void parsePath(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    String[] valueParts = value.split(",");
+    String[] result = new String[valueParts.length];
+    for (int i = 0; i < valueParts.length; i++) {
+      if (valueParts[i].length() != 41) {
+        throw new DescriptorParseException("Illegal value in '" + keyValue
+            + "' in line '" + line + "'.");
+      }
+      result[i] = ParseHelper.parseTwentyByteHexString(line,
+          valueParts[i].substring(1));
+    }
+    this.path = result;
+  }
+
+  private void parseBuildTimes(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    String[] valueParts = value.split(",");
+    Long[] result = new Long[valueParts.length];
+    for (int i = 0; i < valueParts.length; i++) {
+      result[i] = this.parseTimestamp(valueParts[i], keyValue, line);
+    }
+    this.buildTimes = result;
+  }
+
+  private void parseTimeout(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.timeout = this.parseInt(value, keyValue, line);
+  }
+
+  private void parseQuantile(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.quantile = this.parseDouble(value, keyValue, line);
+  }
+
+  private void parseCircId(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.circId = this.parseInt(value, keyValue, line);
+  }
+
+  private void parseUsedBy(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    this.usedBy = this.parseInt(value, keyValue, line);
+  }
+
+  private long parseTimestamp(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    long timestamp = -1L;
+    if (value.contains(".") && value.split("\\.").length == 2) {
+      String zeroPaddedValue = (value + "000");
+      String threeDecimalPlaces = zeroPaddedValue.substring(0,
+          zeroPaddedValue.indexOf(".") + 4);
+      String millisString = threeDecimalPlaces.replaceAll("\\.", "");
+      try {
+        timestamp = Long.parseLong(millisString);
+      } catch (NumberFormatException e) {
+        /* Handle below. */
+      }
+    }
+    if (timestamp < 0L) {
+      throw new DescriptorParseException("Illegal timestamp '" + value
+          + "' in '" + keyValue + "' in line '" + line + "'.");
+    }
+    return timestamp;
+  }
+
+  private int parseInt(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    try {
+      return Integer.parseInt(value);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal value in '" + keyValue
+          + "' in line '" + line + "'.");
+    }
+  }
+
+  private double parseDouble(String value, String keyValue, String line)
+      throws DescriptorParseException {
+    try {
+      return Double.parseDouble(value);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException("Illegal value in '" + keyValue
+          + "' in line '" + line + "'.");
+    }
+  }
+
+  private SortedMap<String, String> unrecognizedKeys;
+  @Override
+  public SortedMap<String, String> getUnrecognizedKeys() {
+    return this.unrecognizedKeys == null ? null
+        : new TreeMap<>(this.unrecognizedKeys);
+  }
+
+  private String source;
+  @Override
+  public String getSource() {
+    return this.source;
+  }
+
+  private int fileSize;
+  @Override
+  public int getFileSize() {
+    return this.fileSize;
+  }
+
+  private long startMillis;
+  @Override
+  public long getStartMillis() {
+    return this.startMillis;
+  }
+
+  private long socketMillis;
+  @Override
+  public long getSocketMillis() {
+    return this.socketMillis;
+  }
+
+  private long connectMillis;
+  @Override
+  public long getConnectMillis() {
+    return this.connectMillis;
+  }
+
+  private long negotiateMillis;
+  @Override
+  public long getNegotiateMillis() {
+    return this.negotiateMillis;
+  }
+
+  private long requestMillis;
+  @Override
+  public long getRequestMillis() {
+    return this.requestMillis;
+  }
+
+  private long responseMillis;
+  @Override
+  public long getResponseMillis() {
+    return this.responseMillis;
+  }
+
+  private long dataRequestMillis;
+  @Override
+  public long getDataRequestMillis() {
+    return this.dataRequestMillis;
+  }
+
+  private long dataResponseMillis;
+  @Override
+  public long getDataResponseMillis() {
+    return this.dataResponseMillis;
+  }
+
+  private long dataCompleteMillis;
+  @Override
+  public long getDataCompleteMillis() {
+    return this.dataCompleteMillis;
+  }
+
+  private int writeBytes;
+  @Override
+  public int getWriteBytes() {
+    return this.writeBytes;
+  }
+
+  private int readBytes;
+  @Override
+  public int getReadBytes() {
+    return this.readBytes;
+  }
+
+  private boolean didTimeout;
+  @Override
+  public Boolean didTimeout() {
+    return this.didTimeout;
+  }
+
+  private SortedMap<Integer, Long> dataPercentiles;
+  @Override
+  public SortedMap<Integer, Long> getDataPercentiles() {
+    return this.dataPercentiles == null ? null
+        : new TreeMap<>(this.dataPercentiles);
+  }
+
+  private long launchMillis = -1L;
+  @Override
+  public long getLaunchMillis() {
+    return this.launchMillis;
+  }
+
+  private long usedAtMillis = -1L;
+  @Override
+  public long getUsedAtMillis() {
+    return this.usedAtMillis;
+  }
+
+  private String[] path;
+  @Override
+  public List<String> getPath() {
+    return this.path == null ? null : Arrays.asList(this.path);
+  }
+
+  private Long[] buildTimes;
+  @Override
+  public List<Long> getBuildTimes() {
+    return this.buildTimes == null ? null :
+        Arrays.asList(this.buildTimes);
+  }
+
+  private long timeout = -1L;
+  @Override
+  public long getTimeout() {
+    return this.timeout;
+  }
+
+  private double quantile = -1.0;
+  @Override
+  public double getQuantile() {
+    return this.quantile;
+  }
+
+  private int circId = -1;
+  @Override
+  public int getCircId() {
+    return this.circId;
+  }
+
+  private int usedBy = -1;
+  @Override
+  public int getUsedBy() {
+    return this.usedBy;
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/package-info.java b/src/main/java/org/torproject/descriptor/package-info.java
new file mode 100644
index 0000000..5b34554
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/package-info.java
@@ -0,0 +1,80 @@
+/* Copyright 2016 The Tor Project
+ * See LICENSE for licensing information */
+
+/**
+ * Interfaces and essential classes for obtaining and processing Tor
+ * descriptors.
+ *
+ * <p>This package contains all relevant interfaces and
+ * classes that an application would need to use this library.
+ * Applications are strongly discouraged from accessing types from the
+ * implementation package ({@code org.torproject.descriptor.impl})
+ * directly, because those may change without prior notice.</p>
+ *
+ * <p>Interfaces and classes in this package can be grouped into
+ * general-purpose types to obtain and process any type of descriptor and
+ * descriptors produced by different components of the Tor network:</p>
+ *
+ * <ol>
+ * <li>General-purpose types comprise
+ * {@link org.torproject.descriptor.DescriptorSourceFactory} which is the
+ * main entry point into using this library.  This factory is used to
+ * create the descriptor sources for obtaining remote descriptor data
+ * ({@link org.torproject.descriptor.DescriptorDownloader} and
+ * {@link org.torproject.descriptor.DescriptorCollector}) and descriptor
+ * sources for processing local descriptor data
+ * ({@link org.torproject.descriptor.DescriptorReader} and
+ * {@link org.torproject.descriptor.DescriptorParser}).  General-purpose
+ * types also include descriptor containers
+ * ({@link org.torproject.descriptor.DescriptorRequest} and
+ * {@link org.torproject.descriptor.DescriptorFile}) and the
+ * superinterface for all provided descriptors
+ * ({@link org.torproject.descriptor.Descriptor}).</li>
+ *
+ * <li>The first group of descriptors is published by relays and servers
+ * in the Tor network.  These interfaces include server descriptors
+ * ({@link org.torproject.descriptor.ServerDescriptor} with subinterfaces
+ * {@link org.torproject.descriptor.RelayServerDescriptor} and
+ * {@link org.torproject.descriptor.BridgeServerDescriptor}), extra-info
+ * descriptors ({@link org.torproject.descriptor.ExtraInfoDescriptor} with
+ * subinterfaces
+ * {@link org.torproject.descriptor.RelayExtraInfoDescriptor} and
+ * {@link org.torproject.descriptor.BridgeExtraInfoDescriptor}),
+ * microdescriptors which are derived from server descriptors by the
+ * directory authorities
+ * ({@link org.torproject.descriptor.Microdescriptor}), and helper types
+ * for parts of the aforementioned descriptors
+ * ({@link org.torproject.descriptor.BandwidthHistory}).</li>
+ *
+ * <li>The second group of descriptors is generated by authoritative
+ * directory servers that form an opinion about relays and bridges in the
+ * Tor network.  These include descriptors specified in version 3 of the
+ * directory protocol
+ * ({@link org.torproject.descriptor.RelayNetworkStatusConsensus},
+ * {@link org.torproject.descriptor.RelayNetworkStatusVote},
+ * {@link org.torproject.descriptor.DirectoryKeyCertificate}, and helper
+ * types for descriptor parts
+ * {@link org.torproject.descriptor.DirSourceEntry},
+ * {@link org.torproject.descriptor.NetworkStatusEntry}, and
+ * {@link org.torproject.descriptor.DirectorySignature}), descriptors from
+ * earlier directory protocol version 2
+ * ({@link org.torproject.descriptor.RelayNetworkStatus}) and version 1
+ * ({@link org.torproject.descriptor.RelayDirectory} and
+ * {@link org.torproject.descriptor.RouterStatusEntry}), as well as
+ * descriptors published by the bridge authority and sanitized by the
+ * CollecTor service
+ * ({@link org.torproject.descriptor.BridgeNetworkStatus}).</li>
+ *
+ * <li>The third group of descriptors is created by auxiliary services
+ * connected to the Tor network rather than by the Tor software.  This
+ * group comprises descriptors by the bridge distribution service BridgeDB
+ * ({@link org.torproject.descriptor.BridgePoolAssignment}), the exit list
+ * service TorDNSEL ({@link org.torproject.descriptor.ExitList}), and the
+ * performance measurement service Torperf
+ * ({@link org.torproject.descriptor.TorperfResult}).</li>
+ * </ol>
+ *
+ * @since 1.0.0
+ */
+package org.torproject.descriptor;
+
diff --git a/src/org/torproject/descriptor/BandwidthHistory.java b/src/org/torproject/descriptor/BandwidthHistory.java
deleted file mode 100644
index 0be1a53..0000000
--- a/src/org/torproject/descriptor/BandwidthHistory.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.SortedMap;
-
-/**
- * Contains the bandwidth history of a relay or bridge.
- *
- * <p>A bandwidth history is not a descriptor type of its own but usually
- * part of extra-info descriptors ({@link ExtraInfoDescriptor}) or server
- * descriptors ({@link ServerDescriptor}).</p>
- *
- * @since 1.0.0
- */
-public interface BandwidthHistory {
-
-  /**
-   * Return the original bandwidth history line as contained in the
-   * descriptor, possibly prefixed with {@code "opt "}.
-   *
-   * @since 1.0.0
-   */
-  public String getLine();
-
-  /**
-   * Return the time in milliseconds since the epoch when the most recent
-   * interval ends.
-   *
-   * @since 1.0.0
-   */
-  public long getHistoryEndMillis();
-
-  /**
-   * Return the interval length in seconds.
-   *
-   * @since 1.0.0
-   */
-  public long getIntervalLength();
-
-  /**
-   * Return the (possibly empty) bandwidth history with map keys being
-   * times in milliseconds since the epoch when intervals end and map
-   * values being number of bytes used in the interval, ordered from
-   * oldest to newest interval.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<Long, Long> getBandwidthValues();
-}
-
diff --git a/src/org/torproject/descriptor/BridgeExtraInfoDescriptor.java b/src/org/torproject/descriptor/BridgeExtraInfoDescriptor.java
deleted file mode 100644
index a3c168d..0000000
--- a/src/org/torproject/descriptor/BridgeExtraInfoDescriptor.java
+++ /dev/null
@@ -1,25 +0,0 @@
-/* Copyright 2015--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains a sanitized bridge extra-info descriptor.
- *
- * <p>Sanitized bridge extra-info descriptors share many contents with
- * relay extra-info descriptors ({@link RelayExtraInfoDescriptor}), which
- * is why they share a common
- * superinterface ({@link ExtraInfoDescriptor}).  The main purpose of
- * having two subinterfaces is being able to distinguish descriptor types
- * more easily.</p>
- *
- * <p>Details about sanitizing bridge extra-info descriptors can be found
- * <a href="https://collector.torproject.org/#type-bridge-extra-info";>here</a>.
- * </p>
- *
- * @since 1.1.0
- */
-public interface BridgeExtraInfoDescriptor extends ExtraInfoDescriptor {
-
-}
-
diff --git a/src/org/torproject/descriptor/BridgeNetworkStatus.java b/src/org/torproject/descriptor/BridgeNetworkStatus.java
deleted file mode 100644
index c7458fd..0000000
--- a/src/org/torproject/descriptor/BridgeNetworkStatus.java
+++ /dev/null
@@ -1,128 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.SortedMap;
-
-/**
- * Contains a sanitized bridge network status document.
- *
- * <p>The bridge directory authority periodically publishes a network
- * status document with one entry per known bridge in the network
- * ({@link NetworkStatusEntry}) containing: a hash of its identity key, a
- * hash of its most recent server descriptor, and a summary of what the
- * bridge authority believed about its status.</p>
- *
- * <p>The main purpose of this document is to get an authoritative list of
- * running bridges to the bridge distribution service BridgeDB.</p>
- *
- * <p>Details about sanitizing bridge network statuses can be found
- * <a href="https://collector.torproject.org/#type-bridge-network-status";>here</a>.
- * </p>
- *
- * @since 1.0.0
- */
-public interface BridgeNetworkStatus extends Descriptor {
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the minimum uptime in seconds that this authority requires
-   * for assigning the Stable flag, or -1 if the authority doesn't report
-   * this value.
-   *
-   * @since 1.1.0
-   */
-  public long getStableUptime();
-
-  /**
-   * Return the minimum MTBF (mean time between failure) that this
-   * authority requires for assigning the Stable flag, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.1.0
-   */
-  public long getStableMtbf();
-
-  /**
-   * Return the minimum bandwidth that this authority requires for
-   * assigning the Fast flag, or -1 if the authority doesn't report this
-   * value.
-   *
-   * @since 1.1.0
-   */
-  public long getFastBandwidth();
-
-  /**
-   * Return the minimum WFU (weighted fractional uptime) in percent that
-   * this authority requires for assigning the Guard flag, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.1.0
-   */
-  public double getGuardWfu();
-
-  /**
-   * Return the minimum weighted time in seconds that this authority
-   * needs to know about a relay before assigning the Guard flag, or -1 if
-   * the authority doesn't report this information.
-   *
-   * @since 1.1.0
-   */
-  public long getGuardTk();
-
-  /**
-   * Return the minimum bandwidth that this authority requires for
-   * assigning the Guard flag if exits can be guards, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.1.0
-   */
-  public long getGuardBandwidthIncludingExits();
-
-  /**
-   * Return the minimum bandwidth that this authority requires for
-   * assigning the Guard flag if exits can not be guards, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.1.0
-   */
-  public long getGuardBandwidthExcludingExits();
-
-  /**
-   * Return 1 if the authority has measured enough MTBF info to use the
-   * MTBF requirement instead of the uptime requirement for assigning the
-   * Stable flag, 0 if not, or -1 if the authority doesn't report this
-   * information.
-   *
-   * @since 1.1.0
-   */
-  public int getEnoughMtbfInfo();
-
-  /**
-   * Return 1 if the authority has enough measured bandwidths that it'll
-   * ignore the advertised bandwidth claims of routers without measured
-   * bandwidth, 0 if not, or -1 if the authority doesn't report this
-   * information.
-   *
-   * @since 1.1.0
-   */
-  public int getIgnoringAdvertisedBws();
-
-  /**
-   * Return status entries for each contained bridge, with map keys being
-   * SHA-1 digests of SHA-1 digest of the bridges' public identity keys,
-   * encoded as 40 upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
-}
-
diff --git a/src/org/torproject/descriptor/BridgePoolAssignment.java b/src/org/torproject/descriptor/BridgePoolAssignment.java
deleted file mode 100644
index 2de4ee9..0000000
--- a/src/org/torproject/descriptor/BridgePoolAssignment.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.SortedMap;
-
-/**
- * Contains a sanitized list of bridges together with the distribution
- * pools they have been assigned to by the bridge distribution service
- * BridgeDB.
- *
- * <p>BridgeDB receives bridge network statuses
- * ({@link BridgeNetworkStatus}) from the bridge authority, assigns these
- * bridges to persistent distribution rings, and hands them out to bridge
- * users.  BridgeDB periodically dumps the list of running bridges with
- * information about the rings, subrings, and file buckets to which they
- * are assigned to a local file.</p>
- *
- * <p>Details about sanitizing bridge pool assignments can be found
- * <a href="https://collector.torproject.org/#type-bridge-pool-assignment";>here</a>.
- * </p>
- *
- * @since 1.0.0
- */
-public interface BridgePoolAssignment extends Descriptor {
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the entries contained in this bridge pool assignment list
-   * with map keys being SHA-1 digests of SHA-1 digest of the bridges'
-   * public identity keys, encoded as 40 upper-case hexadecimal
-   * characters, and map values being assignment strings, e.g.
-   * {@code "https ring=3 flag=stable"}.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, String> getEntries();
-}
-
diff --git a/src/org/torproject/descriptor/BridgeServerDescriptor.java b/src/org/torproject/descriptor/BridgeServerDescriptor.java
deleted file mode 100644
index 7d4503f..0000000
--- a/src/org/torproject/descriptor/BridgeServerDescriptor.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2015--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains a sanitized bridge server descriptor.
- *
- * <p>Sanitized bridge server descriptors share many contents with relay
- * server descriptors ({@link RelayServerDescriptor}), which is why they
- * share a common superinterface ({@link ServerDescriptor}).  The main
- * purpose of having two subinterfaces is being able to distinguish
- * descriptor types more easily.</p>
- *
- * <p>Details about sanitizing bridge server descriptors can be found
- * <a href="https://collector.torproject.org/#type-bridge-server-descriptor";>here</a>.
- * </p>
- *
- * @since 1.1.0
- */
-public interface BridgeServerDescriptor extends ServerDescriptor {
-
-}
-
diff --git a/src/org/torproject/descriptor/Descriptor.java b/src/org/torproject/descriptor/Descriptor.java
deleted file mode 100644
index 7cad109..0000000
--- a/src/org/torproject/descriptor/Descriptor.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-
-/**
- * Superinterface for any descriptor with access to generic information
- * about the descriptor.
- *
- * @since 1.0.0
- */
-public interface Descriptor {
-
-  /**
-   * Return the raw descriptor bytes.
-   *
-   * @since 1.0.0
-   */
-  public byte[] getRawDescriptorBytes();
-
-  /**
-   * Return the (possibly empty) list of annotations in the format
-   * {@code "@key( value)*"}.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getAnnotations();
-
-  /**
-   * Return any unrecognized lines when parsing this descriptor, or an
-   * empty list if there were no unrecognized lines.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getUnrecognizedLines();
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorCollector.java b/src/org/torproject/descriptor/DescriptorCollector.java
deleted file mode 100644
index b1027dc..0000000
--- a/src/org/torproject/descriptor/DescriptorCollector.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/* Copyright 2015--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.io.File;
-
-/**
- * Descriptor source that synchronizes descriptors from the CollecTor
- * service to a given local directory.
- *
- * <p>This type is not a descriptor source in the proper sense, because it
- * does not produce descriptors by itself.  But it often creates the
- * prerequisites for reading descriptors from disk using
- * {@link DescriptorReader}.</p>
- *
- * <p>Code sample:</p>
- * <pre>{@code
- * DescriptorCollector descriptorCollector =
- *     DescriptorSourceFactory.createDescriptorCollector();
- * descriptorCollector.collectDescriptors(
- *     // Download from Tor's main CollecTor instance,
- *     "https://collector.torproject.org";,
- *     // include network status consensuses and relay server descriptors
- *     new String[] { "/recent/relay-descriptors/consensuses/",
- *     "/recent/relay-descriptors/server-descriptors/" },
- *     // regardless of last-modified time,
- *     0L,
- *     // write to the local directory called in/,
- *     new File("in"),
- *     // and delete extraneous files that do not exist remotely anymore.
- *     true);
- * }</pre>
- *
- * @since 1.0.0
- */
-public interface DescriptorCollector {
-
-  /**
-   * Fetch remote files from a CollecTor instance that do not yet exist
-   * locally and possibly delete local files that do not exist remotely
-   * anymore.
-   *
-   * @param collecTorBaseUrl CollecTor base URL without trailing slash,
-   *     e.g., {@code "https://collector.torproject.org"}
-   * @param remoteDirectories Remote directories to collect descriptors
-   *     from, e.g.,
-   *     {@code "/recent/relay-descriptors/server-descriptors/"}, without
-   *     processing subdirectories unless they are explicitly listed
-   * @param minLastModified Minimum last-modified time in milliseconds of
-   *     files to be collected, or 0 for collecting all files
-   * @param localDirectory Directory where collected files will be written
-   * @param deleteExtraneousLocalFiles Whether to delete all local files
-   *     that do not exist remotely anymore
-   *
-   * @since 1.0.0
-   */
-  public void collectDescriptors(String collecTorBaseUrl,
-      String[] remoteDirectories, long minLastModified,
-      File localDirectory, boolean deleteExtraneousLocalFiles);
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorDownloader.java b/src/org/torproject/descriptor/DescriptorDownloader.java
deleted file mode 100644
index f0b1101..0000000
--- a/src/org/torproject/descriptor/DescriptorDownloader.java
+++ /dev/null
@@ -1,198 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.Iterator;
-import java.util.Set;
-
-/**
- * Descriptor source that downloads relay descriptors from directory
- * authorities or mirrors.
- *
- * <p>Downloading descriptors is done in a batch which starts after
- * setting any configuration options and initiating the download
- * process.</p>
- *
- * @since 1.0.0
- */
-public interface DescriptorDownloader {
-
-  /**
-   * Add a directory authority to download descriptors from, which is
-   * only required for downloading network status votes and will be used
-   * when no directory mirrors are available.
-   *
-   * @since 1.0.0
-   */
-  public void addDirectoryAuthority(String nickname, String ip,
-      int dirPort);
-
-  /**
-   * Add a directory mirror to download descriptors from, which is
-   * preferred for downloading descriptors, except for network status
-   * votes which are only available on directory authorities.
-   *
-   * @since 1.0.0
-   */
-  public void addDirectoryMirror(String nickname, String ip, int dirPort);
-
-  /**
-   * Include the current network status consensus in the downloads.
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeCurrentConsensus();
-
-  /**
-   * Include the current network status consensus in the downloads, and
-   * attempt to download it from all directory authorities.
-   *
-   * <p>The primary purpose of doing this is to compare different
-   * consensuses and download characteristics to each other.  Typically,
-   * downloading from a single directory mirror or authority is
-   * sufficient.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeCurrentConsensusFromAllDirectoryAuthorities();
-
-  /**
-   * Include the current network status votes referenced from a
-   * previously downloaded consensus in the downloads, which requires
-   * downloading the current consensus from at least one directory mirror
-   * or authority.
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeCurrentReferencedVotes();
-
-  /**
-   * Include the current network status vote published by the given
-   * directory authority in the downloads, which requires downloading from
-   * at least one directory authority.
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeCurrentVote(String fingerprint);
-
-  /**
-   * Include the current network status votes published by the given
-   * directory authorities in the downloads, which requires downloading
-   * from at least one directory authority.
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeCurrentVotes(Set<String> fingerprints);
-
-  /**
-   * Include all server descriptors referenced from a previously
-   * downloaded network status consensus in the downloads.
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeReferencedServerDescriptors();
-
-  /**
-   * Exclude the server descriptor with the given identifier from the
-   * downloads even if it's referenced from a consensus and we're supposed
-   * to download all referenced server descriptors.
-   *
-   * @since 1.0.0
-   */
-  public void setExcludeServerDescriptor(String identifier);
-
-  /**
-   * Exclude the server descriptors with the given identifiers from the
-   * downloads even if they are referenced from a consensus and we're
-   * supposed to download all referenced server descriptors.
-   *
-   * @since 1.0.0
-   */
-  public void setExcludeServerDescriptors(Set<String> identifier);
-
-  /**
-   * Include all extra-info descriptors referenced from previously
-   * downloaded server descriptors in the downloads.
-   *
-   * @since 1.0.0
-   */
-  public void setIncludeReferencedExtraInfoDescriptors();
-
-  /**
-   * Exclude the extra-info descriptor with the given identifier from the
-   * downloads even if it's referenced from a server descriptor and we're
-   * supposed to download all referenced extra-info descriptors.
-   *
-   * @since 1.0.0
-   */
-  public void setExcludeExtraInfoDescriptor(String identifier);
-
-  /**
-   * Exclude the extra-info descriptors with the given identifiers from
-   * the downloads even if they are referenced from server descriptors
-   * and we're supposed to download all referenced extra-info
-   * descriptors.
-   *
-   * @since 1.0.0
-   */
-  public void setExcludeExtraInfoDescriptors(Set<String> identifiers);
-
-  /**
-   * Define a connect timeout for a single request.
-   *
-   * <p>If a timeout expires, no further requests will be sent to the
-   * directory authority or mirror.  Setting this value to 0 disables the
-   * connect timeout.  Default value is 1 minute (60 * 1000).</p>
-   *
-   * @since 1.0.0
-   */
-  public void setConnectTimeout(long connectTimeoutMillis);
-
-  /**
-   * Define a read timeout for a single request.
-   *
-   * <p>If a timeout expires, no further requests will be sent to the
-   * directory authority or mirror.  Setting this value to 0 disables the
-   * read timeout.  Default value is 1 minute (60 * 1000).</p>
-   *
-   * @since 1.0.0
-   */
-  public void setReadTimeout(long readTimeoutMillis);
-
-  /**
-   * Define a global timeout for all requests.
-   *
-   * <p>Once this timeout expires, all running requests are aborted and no
-   * further requests are made.  Setting this value to 0 disables the
-   * global timeout.  Default is 1 hour (60 * 60 * 1000).</p>
-   *
-   * @since 1.0.0
-   */
-  public void setGlobalTimeout(long globalTimeoutMillis);
-
-  /**
-   * Fail descriptor parsing when encountering an unrecognized line.
-   *
-   * <p>This option is not set by default, because the Tor specifications
-   * allow for new lines to be added that shall be ignored by older Tor
-   * versions.  But some applications may want to handle unrecognized
-   * descriptor lines explicitly.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setFailUnrecognizedDescriptorLines();
-
-  /**
-   * Download the previously configured relay descriptors and make them
-   * available via the returned blocking iterator.
-   *
-   * <p>Whenever the downloader runs out of descriptors and expects to
-   * provide more shortly after, it blocks the caller.  This method can
-   * only be run once.</p>
-   *
-   * @since 1.0.0
-   */
-  public Iterator<DescriptorRequest> downloadDescriptors();
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorFile.java b/src/org/torproject/descriptor/DescriptorFile.java
deleted file mode 100644
index 417d7f9..0000000
--- a/src/org/torproject/descriptor/DescriptorFile.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.io.File;
-import java.util.List;
-
-/**
- * Container for descriptors read from a file.
- *
- * <p>When the {@link DescriptorReader} reads descriptors from local files
- * it provides an iterator over these containers which in turn contain
- * references to classes implementing the {@link Descriptor} interface.
- * This container also stores potentially useful meta-data about the
- * descriptor file.</p>
- *
- * @since 1.0.0
- */
-public interface DescriptorFile {
-
-  /**
-   * Return the directory where this descriptor file was contained, or
-   * null if the file was contained in a tarball.
-   *
-   * @since 1.0.0
-   */
-  public File getDirectory();
-
-  /**
-   * Return the tarball where this descriptor file was contained, or null
-   * if the file was not contained in a tarball.
-   *
-   * @since 1.0.0
-   */
-  public File getTarball();
-
-  /**
-   * Return the descriptor file itself, or null if the descriptor file
-   * was contained in a tarball.
-   *
-   * @since 1.0.0
-   */
-  public File getFile();
-
-  /**
-   * Return the descriptor file name, which is either the absolute path
-   * of the file on disk, or the tar file entry name.
-   *
-   * @since 1.0.0
-   */
-  public String getFileName();
-
-  /**
-   * Return the time in milliseconds since the epoch when the descriptor
-   * file on disk was last modified.
-   *
-   * @since 1.0.0
-   */
-  public long getLastModified();
-
-  /**
-   * Return the descriptors contained in the descriptor file.
-   *
-   * @since 1.0.0
-   */
-  public List<Descriptor> getDescriptors();
-
-  /**
-   * Return the first exception that was thrown when reading this file or
-   * parsing its content, or null if no exception was thrown.
-   *
-   * @since 1.0.0
-   */
-  public Exception getException();
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorParseException.java b/src/org/torproject/descriptor/DescriptorParseException.java
deleted file mode 100644
index 309d3f7..0000000
--- a/src/org/torproject/descriptor/DescriptorParseException.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/* Copyright 2014--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Thrown if raw descriptor contents cannot be parsed to one or more
- * {@link Descriptor} instances, according to descriptor specifications.
- *
- * @since 1.0.0
- */
-@SuppressWarnings("deprecation")
-public class DescriptorParseException
-    extends org.torproject.descriptor.impl.DescriptorParseException {
-  private static final long serialVersionUID = 100L;
-  public DescriptorParseException(String message) {
-    super(message);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorParser.java b/src/org/torproject/descriptor/DescriptorParser.java
deleted file mode 100644
index 680b8b2..0000000
--- a/src/org/torproject/descriptor/DescriptorParser.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-
-/**
- * Descriptor source that parses descriptors from raw descriptor contents.
- *
- * <p>Unlike most of the other descriptor sources this descriptor source
- * does not operate in a batch-processing mode.  It takes the raw
- * descriptor contents of one or more descriptors, parses them, and
- * returns a list of descriptors.</p>
- *
- * <p>This descriptor source is internally used by other descriptor
- * sources but can also be used directly by applications that obtain
- * raw descriptor contents via other means than one of the existing
- * descriptor sources.</p>
- *
- * @since 1.0.0
- */
-public interface DescriptorParser {
-
-  /**
-   * Fail descriptor parsing when encountering an unrecognized line.
-   *
-   * <p>This option is not set by default, because the Tor specifications
-   * allow for new lines to be added that shall be ignored by older Tor
-   * versions.  But some applications may want to handle unrecognized
-   * descriptor lines explicitly.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setFailUnrecognizedDescriptorLines(
-      boolean failUnrecognizedDescriptorLines);
-
-  /**
-   * Parse descriptors in the given byte array, possibly parsing the
-   * publication time from the file name, depending on the descriptor
-   * type.
-   *
-   * @since 1.0.0
-   */
-  public List<Descriptor> parseDescriptors(byte[] rawDescriptorBytes,
-      String fileName) throws DescriptorParseException;
-}
diff --git a/src/org/torproject/descriptor/DescriptorReader.java b/src/org/torproject/descriptor/DescriptorReader.java
deleted file mode 100644
index 771755e..0000000
--- a/src/org/torproject/descriptor/DescriptorReader.java
+++ /dev/null
@@ -1,143 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.io.File;
-import java.util.Iterator;
-import java.util.SortedMap;
-
-/**
- * Descriptor source that reads descriptors from local files and provides
- * an iterator over parsed descriptors.
- *
- * <p>This descriptor source is likely the most widely used one, possibly
- * in combination with {@link DescriptorCollector} to synchronize
- * descriptors from the CollecTor service.</p>
- *
- * <p>Reading descriptors is done in a batch which starts after setting
- * any configuration options and initiating the read process.</p>
- *
- * <p>Code sample:</p>
- * <pre>{@code
- * DescriptorReader descriptorReader =
- *     DescriptorSourceFactory.createDescriptorReader();
- * // Read descriptors from local directory called in/.
- * descriptorReader.addDirectory(new File("in"));
- * Iterator<DescriptorFile> descriptorFiles =
- *     descriptorReader.readDescriptors();
- * while (descriptorFiles.hasNext()) {
- *   DescriptorFile descriptorFile = descriptorFiles.next();
- *   for (Descriptor descriptor : descriptorFile.getDescriptors()) {
- *     if ((descriptor instanceof RelayNetworkStatusConsensus)) {
- *       // Only process network status consensuses, ignore the rest.
- *       RelayNetworkStatusConsensus consensus =
- *           (RelayNetworkStatusConsensus) descriptor;
- *       processConsensus(consensus);
- *     }
- *   }
- * }}</pre>
- *
- * @since 1.0.0
- */
-public interface DescriptorReader {
-
-  /**
-   * Add a local directory to read descriptors from, which may contain
-   * descriptor files or tarballs containing descriptor files.
-   *
-   * @since 1.0.0
-   */
-  public void addDirectory(File directory);
-
-  /**
-   * Add a tarball to read descriptors from, which may be uncompressed,
-   * bz2-compressed, or xz-compressed.
-   *
-   * @since 1.0.0
-   */
-  public void addTarball(File tarball);
-
-  /**
-   * Exclude files that are listed in the given history file and that
-   * haven't changed since they have last been read.
-   *
-   * <p>Add a new line for each descriptor that is read in this execution
-   * and remove lines for files that don't exist anymore.</p>
-   *
-   * <p>Lines in the history file contain the last modified time in
-   * milliseconds since the epoch and the absolute path of a file.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setExcludeFiles(File historyFile);
-
-  /**
-   * Exclude files if they haven't changed since the corresponding last
-   * modified timestamps.
-   *
-   * <p>Can be used instead of (or in addition to) a history file.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setExcludedFiles(SortedMap<String, Long> excludedFiles);
-
-  /**
-   * Return files and last modified timestamps of files that exist in the
-   * input directory or directories, but that have been excluded from
-   * parsing, because they haven't changed since they were last read.
-   *
-   * <p>Can be used instead of (or in addition to) a history file when
-   * combined with the set of parsed files.</p>
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Long> getExcludedFiles();
-
-  /**
-   * Return files and last modified timestamps of files that exist in the
-   * input directory or directories and that have been parsed.
-   *
-   * <p>Can be used instead of (or in addition to) a history file when
-   * combined with the set of excluded files.</p>
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Long> getParsedFiles();
-
-  /**
-   * Fail descriptor parsing when encountering an unrecognized line.
-   *
-   * <p>This option is not set by default, because the Tor specifications
-   * allow for new lines to be added that shall be ignored by older Tor
-   * versions.  But some applications may want to handle unrecognized
-   * descriptor lines explicitly.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setFailUnrecognizedDescriptorLines();
-
-  /**
-   * Don't keep more than this number of parsed descriptor files in the
-   * queue.
-   *
-   * <p>The default is 100, but if descriptor files contain hundreds or
-   * even thousands of descriptors, that default may be too high.</p>
-   *
-   * @since 1.0.0
-   */
-  public void setMaxDescriptorFilesInQueue(int max);
-
-  /**
-   * Read the previously configured descriptors and make them available
-   * via the returned blocking iterator.
-   *
-   * <p>Whenever the reader runs out of descriptors and expects to provide
-   * more shortly after, it blocks the caller.  This method can only be
-   * run once.</p>
-   *
-   * @since 1.0.0
-   */
-  public Iterator<DescriptorFile> readDescriptors();
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorRequest.java b/src/org/torproject/descriptor/DescriptorRequest.java
deleted file mode 100644
index c36c0c0..0000000
--- a/src/org/torproject/descriptor/DescriptorRequest.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-
-/**
- * Container for descriptors downloaded from a directory authority or
- * mirror.
- *
- * <p>When the {@link DescriptorDownloader} downloads descriptors from
- * directory authorities or mirrors it provides an iterator over these
- * containers which in turn contain references to classes implementing the
- * {@link Descriptor} interface.  This container also stores potentially
- * useful meta-data about the descriptor request.</p>
- *
- * @since 1.0.0
- */
-public interface DescriptorRequest {
-
-  /**
-   * Return the request URL that was used in this request.
-   *
-   * @since 1.0.0
-   */
-  public String getRequestUrl();
-
-  /**
-   * Return the nickname of the directory mirror or authority as
-   * previously configured.
-   *
-   * @since 1.0.0
-   */
-  public String getDirectoryNickname();
-
-  /**
-   * Return the first exception that was thrown when making this request
-   * or parsing the response, or null if no exception was thrown.
-   *
-   * @since 1.0.0
-   */
-  public Exception getException();
-
-  /**
-   * Return the response code that the directory mirror or authority
-   * returned.
-   *
-   * @since 1.0.0
-   */
-  public int getResponseCode();
-
-  /**
-   * Return the time in milliseconds since the epoch when this request
-   * was started.
-   *
-   * @since 1.0.0
-   */
-  public long getRequestStart();
-
-  /**
-   * Return the time in milliseconds since the epoch when this request
-   * ended.
-   *
-   * @since 1.0.0
-   */
-  public long getRequestEnd();
-
-  /**
-   * Return whether this request ended, because the connect timeout has
-   * expired.
-   *
-   * @since 1.0.0
-   */
-  public boolean connectTimeoutHasExpired();
-
-  /**
-   * Return whether this request ended, because the read timeout has
-   * expired.
-   *
-   * @since 1.0.0
-   */
-  public boolean readTimeoutHasExpired();
-
-  /**
-   * Return whether this request ended, because the global timeout for
-   * all requests has expired.
-   *
-   * @since 1.0.0
-   */
-  public boolean globalTimeoutHasExpired();
-
-  /**
-   * Return the descriptors contained in the reply.
-   *
-   * @since 1.0.0
-   */
-  public List<Descriptor> getDescriptors();
-}
-
diff --git a/src/org/torproject/descriptor/DescriptorSourceFactory.java b/src/org/torproject/descriptor/DescriptorSourceFactory.java
deleted file mode 100644
index af13f39..0000000
--- a/src/org/torproject/descriptor/DescriptorSourceFactory.java
+++ /dev/null
@@ -1,187 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Factory for descriptor sources which in turn produce descriptors.
- *
- * <p>Descriptor sources are the only producers of classes implementing
- * the {@link Descriptor} superinterface.  There exist descriptor sources
- * for obtaining remote descriptor data ({@link DescriptorDownloader} and
- * {@link DescriptorCollector}) and descriptor sources for processing
- * local descriptor data ({@link DescriptorReader} and
- * {@link DescriptorParser}).</p>
- *
- * <p>By default, this factory returns implementations from the library's
- * own impl package.  This may be overridden by setting Java properties,
- * though most users will simply use the default implementations.</p>
- *
- * <p>These properties can be used for setting the implementation:</p>
- * <ul>
- *   <li>{@code descriptor.collector}</li>
- *   <li>{@code descriptor.downloader}</li>
- *   <li>{@code descriptor.parser}</li>
- *   <li>{@code descriptor.reader}</li>
- * </ul>
- *
- * <p>Assuming the classpath contains the special implementation
- * referenced, your application classes as well as a descriptor API jar
- * the following is an example for using a different implementation of the
- * descriptor downloader:</p>
- *
- * <p><code>
- *  java -Ddescriptor.downloader=my.special.descriptorimpl.Downloader my.app.Mainclass
- * </code></p>
- *
- * @since 1.0.0
- */
-public final class DescriptorSourceFactory {
-
-  /**
-   * Default implementation of the {@link DescriptorDownloader}
-   * descriptor source.
-   *
-   * @since 1.0.0
-   */
-  public final static String DOWNLOADER_DEFAULT =
-      "org.torproject.descriptor.impl.DescriptorDownloaderImpl";
-
-  /**
-   * Default implementation of the {@link DescriptorParser} descriptor
-   * source.
-   *
-   * @since 1.0.0
-   */
-  public final static String PARSER_DEFAULT =
-      "org.torproject.descriptor.impl.DescriptorParserImpl";
-
-  /**
-   * Default implementation of the {@link DescriptorReader} descriptor
-   * source.
-   *
-   * @since 1.0.0
-   */
-  public final static String READER_DEFAULT =
-      "org.torproject.descriptor.impl.DescriptorReaderImpl";
-
-  /**
-   * Default implementation of the {@link DescriptorCollector} descriptor
-   * source.
-   *
-   * @since 1.0.0
-   */
-  public final static String COLLECTOR_DEFAULT =
-      "org.torproject.descriptor.impl.DescriptorCollectorImpl";
-
-  /**
-   * Property name for overriding the implementation of the
-   * {@link DescriptorParser} descriptor source, which is by default set
-   * to the class in {@link #PARSER_DEFAULT}.
-   *
-   * @since 1.0.0
-   */
-  public final static String PARSER_PROPERTY = "descriptor.parser";
-
-  /**
-   * Property name for overriding the implementation of the
-   * {@link DescriptorReader} descriptor source, which is by default set
-   * to the class in {@link #READER_DEFAULT}.
-   *
-   * @since 1.0.0
-   */
-  public final static String READER_PROPERTY = "descriptor.reader";
-
-  /**
-   * Property name for overriding the implementation of the
-   * {@link DescriptorDownloader} descriptor source, which is by default
-   * set to the class in {@link #DOWNLOADER_DEFAULT}.
-   *
-   * @since 1.0.0
-   */
-  public final static String DOWNLOADER_PROPERTY =
-      "descriptor.downloader";
-
-  /**
-   * Property name for overriding the implementation of the
-   * {@link DescriptorCollector} descriptor source, which is by default
-   * set to the class in {@link #COLLECTOR_DEFAULT}.
-   *
-   * @since 1.0.0
-   */
-  public final static String COLLECTOR_PROPERTY = "descriptor.collector";
-
-  /**
-   * Create a new {@link DescriptorParser} by instantiating the class in
-   * {@link #PARSER_PROPERTY}.
-   *
-   * @since 1.0.0
-   */
-  public final static DescriptorParser createDescriptorParser() {
-    return (DescriptorParser) retrieve(PARSER_PROPERTY);
-  }
-
-  /**
-   * Create a new {@link DescriptorReader} by instantiating the class in
-   * {@link #READER_PROPERTY}.
-   *
-   * @since 1.0.0
-   */
-  public final static DescriptorReader createDescriptorReader() {
-    return (DescriptorReader) retrieve(READER_PROPERTY);
-  }
-
-  /**
-   * Create a new {@link DescriptorDownloader} by instantiating the class
-   * in {@link #DOWNLOADER_PROPERTY}.
-   *
-   * @since 1.0.0
-   */
-  public final static DescriptorDownloader createDescriptorDownloader() {
-    return (DescriptorDownloader) retrieve(DOWNLOADER_PROPERTY);
-  }
-
-  /**
-   * Create a new {@link DescriptorCollector} by instantiating the class
-   * in {@link #COLLECTOR_PROPERTY}.
-   *
-   * @since 1.0.0
-   */
-  public final static DescriptorCollector createDescriptorCollector() {
-    return (DescriptorCollector) retrieve(COLLECTOR_PROPERTY);
-  }
-
-  private final static <T> Object retrieve(String type) {
-    Object object;
-    String clazzName = null;
-    try {
-      switch (type) {
-      case PARSER_PROPERTY:
-        clazzName = System.getProperty(type, PARSER_DEFAULT);
-        break;
-      case DOWNLOADER_PROPERTY:
-        clazzName = System.getProperty(type, DOWNLOADER_DEFAULT);
-        break;
-      case READER_PROPERTY:
-        clazzName = System.getProperty(type, READER_DEFAULT);
-        break;
-      case COLLECTOR_PROPERTY:
-        clazzName = System.getProperty(type, COLLECTOR_DEFAULT);
-        break;
-      }
-      object = ClassLoader.getSystemClassLoader().loadClass(clazzName).
-          newInstance();
-    } catch (ClassNotFoundException ex) {
-      throw new ImplementationNotAccessibleException("Cannot load class "
-          + clazzName + "for type " + type, ex);
-    } catch (InstantiationException ex) {
-      throw new ImplementationNotAccessibleException("Cannot load class "
-          + clazzName + "for type " + type, ex);
-    } catch (IllegalAccessException ex) {
-      throw new ImplementationNotAccessibleException("Cannot load class "
-          + clazzName + "for type " + type, ex);
-    }
-    return object;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/DirSourceEntry.java b/src/org/torproject/descriptor/DirSourceEntry.java
deleted file mode 100644
index 96d81ee..0000000
--- a/src/org/torproject/descriptor/DirSourceEntry.java
+++ /dev/null
@@ -1,96 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains details about an authority and its vote that contributed to a
- * consensus.
- *
- * <p>A directory source entry is not a descriptor type of its own but is
- * part of a network status consensus
- * ({@link RelayNetworkStatusConsensus}).</p>
- *
- * @since 1.0.0
- */
-public interface DirSourceEntry {
-
-  /**
-   * Return the raw directory source entry bytes.
-   *
-   * @since 1.0.0
-   */
-  public byte[] getDirSourceEntryBytes();
-
-  /**
-   * Return the authority's nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return a SHA-1 digest of the authority's long-term authority
-   * identity key used for the version 3 directory protocol, encoded as
-   * 40 upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getIdentity();
-
-  /**
-   * Return the authority's hostname.
-   *
-   * @since 1.2.0
-   */
-  public String getHostname();
-
-  /**
-   * Return the authority's primary IPv4 address in dotted-quad format.
-   *
-   * @since 1.0.0
-   */
-  public String getIp();
-
-  /**
-   * Return the TCP port where this authority accepts directory-related
-   * HTTP connections.
-   *
-   * @since 1.0.0
-   */
-  public int getDirPort();
-
-  /**
-   * Return the TCP port where this authority accepts TLS connections for
-   * the main OR protocol.
-   *
-   * @since 1.0.0
-   */
-  public int getOrPort();
-
-  /**
-   * Return whether this directory source entry was created using a
-   * legacy key.
-   *
-   * @since 1.0.0
-   */
-  public boolean isLegacy();
-
-  /**
-   * Return the contact information for this authority, which may contain
-   * non-ASCII characters.
-   *
-   * @since 1.0.0
-   */
-  public String getContactLine();
-
-  /**
-   * Return the SHA-1 vote digest, encoded as 40 lower-case hexadecimal
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getVoteDigest();
-}
-
diff --git a/src/org/torproject/descriptor/DirectoryKeyCertificate.java b/src/org/torproject/descriptor/DirectoryKeyCertificate.java
deleted file mode 100644
index 07211ef..0000000
--- a/src/org/torproject/descriptor/DirectoryKeyCertificate.java
+++ /dev/null
@@ -1,109 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains a key certificate in the version 3 directory protocol.
- *
- * <p>Every directory authority in the version 3 directory protocol uses
- * two keys: a medium-term signing key, and a long-term authority identity
- * key.  (Authorities also have a relay identity key used in their role as
- * a relay and by earlier versions of the directory protocol.)  The
- * identity key is used from time to time to sign new key certificates
- * containing signing keys.  The contained signing key is used to sign key
- * certificates and status documents.</p>
- *
- * @since 1.0.0
- */
-public interface DirectoryKeyCertificate extends Descriptor {
-
-  /**
-   * Return the version of this descriptor, which must be 3 or higher.
-   *
-   * @since 1.0.0
-   */
-  public int getDirKeyCertificateVersion();
-
-  /**
-   * Return the authority's primary IPv4 address in dotted-quad format,
-   * or null if the certificate does not contain an address.
-   *
-   * @since 1.0.0
-   */
-  public String getAddress();
-
-  /**
-   * Return the TCP port where this authority accepts directory-related
-   * HTTP connections, or -1 if the certificate does not contain a port.
-   *
-   * @since 1.0.0
-   */
-  public int getPort();
-
-  /**
-   * Return a SHA-1 digest of the authority's long-term authority
-   * identity key used for the version 3 directory protocol, encoded as
-   * 40 upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return the authority's identity key in PEM format.
-   *
-   * @since 1.0.0
-   */
-  public String getDirIdentityKey();
-
-  /**
-   * Return the time in milliseconds since the epoch when the authority's
-   * signing key and this key certificate were generated.
-   *
-   * @since 1.0.0
-   */
-  public long getDirKeyPublishedMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch after which the
-   * authority's signing key is no longer valid.
-   *
-   * @since 1.0.0
-   */
-  public long getDirKeyExpiresMillis();
-
-  /**
-   * Return the authority's signing key in PEM format.
-   *
-   * @since 1.0.0
-   */
-  public String getDirSigningKey();
-
-  /**
-   * Return the signature of the authority's identity key made using the
-   * authority's signing key, or null if the certificate does not contain
-   * such a signature.
-   *
-   * @since 1.0.0
-   */
-  public String getDirKeyCrosscert();
-
-  /**
-   * Return the certificate signature from the initial item
-   * "dir-key-certificate-version" until the final item
-   * "dir-key-certification", signed with the authority identity key.
-   *
-   * @since 1.0.0
-   */
-  public String getDirKeyCertification();
-
-  /**
-   * Return the SHA-1 certificate digest, encoded as 40 lower-case
-   * hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getCertificateDigest();
-}
-
diff --git a/src/org/torproject/descriptor/DirectorySignature.java b/src/org/torproject/descriptor/DirectorySignature.java
deleted file mode 100644
index 8877a4e..0000000
--- a/src/org/torproject/descriptor/DirectorySignature.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains the signature of a network status consensus or vote.
- *
- * <p>A directory signature is not a descriptor type of its own but is
- * part of a network status consensus
- * ({@link RelayNetworkStatusConsensus}) or vote
- * ({@link RelayNetworkStatusVote}).</p>
- *
- * @since 1.0.0
- */
-public interface DirectorySignature {
-
-  /**
-   * Return the digest algorithm, which is "sha1" by default and which
-   * can be "sha256" or another digest algorithm.
-   *
-   * @since 1.0.0
-   */
-  public String getAlgorithm();
-
-  /**
-   * Return the SHA-1 digest of the authority's long-term identity key in
-   * the version 3 directory protocol, encoded as 40 upper-case
-   * hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getIdentity();
-
-  /**
-   * Return the SHA-1 digest of the authority's medium-term signing key
-   * in the version 3 directory protocol, encoded as 40 upper-case
-   * hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getSigningKeyDigest();
-
-  /**
-   * Return the directory signature string made with the authority's
-   * identity key in the version 3 directory protocol.
-   *
-   * @since 1.0.0
-   */
-  public String getSignature();
-}
-
diff --git a/src/org/torproject/descriptor/ExitList.java b/src/org/torproject/descriptor/ExitList.java
deleted file mode 100644
index 2a5cb2e..0000000
--- a/src/org/torproject/descriptor/ExitList.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Contains an exit list containing the IP addresses of relays that the
- * exit list service TorDNSEL found when exiting through them.
- *
- * @since 1.0.0
- */
-public interface ExitList extends Descriptor {
-
-  /**
-   * End-of-line character expected in exit lists.
-   *
-   * @since 1.0.0
-   */
-  public final static String EOL = "\n";
-
-  /**
-   * Exit list entry containing results from a single exit scan.
-   *
-   * @since 1.1.0
-   */
-  public interface Entry {
-
-    /**
-     * Return the scanned relay's fingerprint, which is a SHA-1 digest of
-     * the relays's public identity key, encoded as 40 upper-case
-     * hexadecimal characters.
-     *
-     * @since 1.1.0
-     */
-    public String getFingerprint();
-
-    /**
-     * Return the time in milliseconds since the epoch when the scanned
-     * relay's last known descriptor was published.
-     *
-     * @since 1.1.0
-     */
-    public long getPublishedMillis();
-
-    /**
-     * Return the time in milliseconds since the epoch when the network
-     * status that this scan was based on was published.
-     *
-     * @since 1.1.0
-     */
-    public long getLastStatusMillis();
-
-    /**
-     * Return the IP addresses that were determined in the scan with map
-     * keys being IPv4 addresses in dotted-quad format and map values
-     * being scan times in milliseconds since the epoch.
-     *
-     * @since 1.1.0
-     */
-    public Map<String, Long> getExitAddresses();
-  }
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was downloaded.
-   *
-   * @since 1.0.0
-   */
-  public long getDownloadedMillis();
-
-  /**
-   * Return the unordered set of exit scan results.
-   *
-   * @since 1.0.0
-   * @deprecated The {@link ExitListEntry} type has been deprecated and
-   *     superseded by {@link ExitList.Entry} which is returned by
-   *     {@link #getEntries()}.
-   */
-  @Deprecated
-  public Set<ExitListEntry> getExitListEntries();
-
-  /**
-   * Return the unordered set of exit scan results.
-   *
-   * @since 1.1.0
-   */
-  public Set<ExitList.Entry> getEntries();
-}
-
diff --git a/src/org/torproject/descriptor/ExitListEntry.java b/src/org/torproject/descriptor/ExitListEntry.java
deleted file mode 100644
index 2a3d79f..0000000
--- a/src/org/torproject/descriptor/ExitListEntry.java
+++ /dev/null
@@ -1,55 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Exit list entry containing results from a single exit scan.
- *
- * @since 1.0.0
- * @deprecated Superseded by {@link ExitList.Entry}.
- */
-@Deprecated
-public interface ExitListEntry extends ExitList.Entry {
-
-  /**
-   * Return the scanned relay's fingerprint, which is a SHA-1 digest of
-   * the relays's public identity key, encoded as 40 upper-case
-   * hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return the time in milliseconds since the epoch when the scanned
-   * relay's last known descriptor was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the network
-   * status that this scan was based on was published.
-   *
-   * @since 1.0.0
-   */
-  public long getLastStatusMillis();
-
-  /**
-   * Return the IPv4 address in dotted-quad format that was determined in
-   * the scan.
-   *
-   * @since 1.0.0
-   */
-  public String getExitAddress();
-
-  /**
-   * Return the scan time in milliseconds since the epoch.
-   *
-   * @since 1.0.0
-   */
-  public long getScanMillis();
-}
-
diff --git a/src/org/torproject/descriptor/ExtraInfoDescriptor.java b/src/org/torproject/descriptor/ExtraInfoDescriptor.java
deleted file mode 100644
index 49efbf3..0000000
--- a/src/org/torproject/descriptor/ExtraInfoDescriptor.java
+++ /dev/null
@@ -1,646 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-
-/**
- * Contains a relay or sanitized bridge extra-info descriptor.
- *
- * <p>Relays publish extra-info descriptors as an addendum to server
- * descriptors ({@link ServerDescriptor}) to report extraneous information
- * to the directory authorities that clients do not need to download in
- * order to function.  This information primarily consists of statistics
- * gathered by the relay about its usage and can take up a lot of
- * descriptor space.  The separation of server descriptors and extra-info
- * descriptors has become less relevant with the introduction of
- * microdescriptors ({@link Microdescriptor}) that are derived from server
- * descriptors by the directory authority and which clients download
- * instead of server descriptors, but it persists.</p>
- *
- * <p>Bridges publish extra-info descriptors to the bridge authority for
- * the same reason, to include statistics about their usage without
- * increasing the directory protocol overhead for bridge clients.  In this
- * case, the separation of server descriptors and extra-info descriptors
- * is slightly more relevant, because there are no microdescriptors for
- * bridges, so that bridge clients still download server descriptors of
- * bridges they're using.  Another reason is that bridges need to include
- * information like details of all the transports they support in their
- * descriptors, and bridge clients using one such transport are not
- * supposed to learn the details of the other transports.</p>
- *
- * <p>It's worth noting that all contents of extra-info descriptors are
- * written and signed by relays and bridges without a third party
- * verifying their correctness.  The (bridge) directory authorities may
- * decide to exclude dishonest servers from the network statuses they
- * produce, but that wouldn't be reflected in extra-info descriptors.</p>
- * 
- * @since 1.0.0
- */
-public interface ExtraInfoDescriptor extends Descriptor {
-
-  /**
-   * Return the SHA-1 descriptor digest, encoded as 40 lower-case (relay
-   * descriptors) or upper-case (bridge descriptors) hexadecimal
-   * characters, that is used to reference this descriptor from a server
-   * descriptor.
-   *
-   * @since 1.0.0
-   */
-  public String getExtraInfoDigest();
-
-  /**
-   * Return the SHA-256 descriptor digest, encoded as 43 base64
-   * characters without padding characters, that may be used to reference
-   * this descriptor from a server descriptor.
-   *
-   * @since 1.1.0
-   */
-  public String getExtraInfoDigestSha256();
-
-  /**
-   * Return the server's nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return a SHA-1 digest of the server's public identity key, encoded
-   * as 40 upper-case hexadecimal characters, that is typically used to
-   * uniquely identify the server.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * and the corresponding server descriptor were generated.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the server's history of read bytes, or null if the descriptor
-   * does not contain a bandwidth history; older Tor versions included
-   * bandwidth histories in their server descriptors
-   * ({@link ServerDescriptor#getReadHistory()}).
-   *
-   * @since 1.0.0
-   */
-  public BandwidthHistory getReadHistory();
-
-  /**
-   * Return the server's history of written bytes, or null if the
-   * descriptor does not contain a bandwidth history; older Tor versions
-   * included bandwidth histories in their server descriptors
-   * ({@link ServerDescriptor#getWriteHistory()}).
-   *
-   * @since 1.0.0
-   */
-  public BandwidthHistory getWriteHistory();
-
-  /**
-   * Return a SHA-1 digest of the GeoIP database file used by this server
-   * to resolve client IP addresses to country codes, encoded as 40
-   * upper-case hexadecimal characters, or null if no GeoIP database
-   * digest is included.
-   *
-   * @since 1.0.0
-   */
-  public String getGeoipDbDigest();
-
-  /**
-   * Return a SHA-1 digest of the GeoIPv6 database file used by this
-   * server to resolve client IP addresses to country codes, encoded as 40
-   * upper-case hexadecimal characters, or null if no GeoIPv6 database
-   * digest is included.
-   *
-   * @since 1.0.0
-   */
-  public String getGeoip6DbDigest();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * directory request statistics interval ended, or -1 if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getDirreqStatsEndMillis();
-
-  /**
-   * Return the interval length of the included directory request
-   * statistics in seconds, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getDirreqStatsIntervalLength();
-
-  /**
-   * Return statistics on unique IP addresses requesting v2 network
-   * statuses with map keys being country codes and map values being
-   * numbers of unique IP addresses rounded up to the nearest multiple of
-   * 8, or null if no such statistics are included (which is the case with
-   * recent Tor versions).
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV2Ips();
-
-  /**
-   * Return statistics on unique IP addresses requesting v3 network
-   * status consensuses of any flavor with map keys being country codes
-   * and map values being numbers of unique IP addresses rounded up to the
-   * nearest multiple of 8, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV3Ips();
-
-  /**
-   * Return statistics on directory requests for v2 network statuses with
-   * map keys being country codes and map values being request numbers
-   * rounded up to the nearest multiple of 8, or null if no such
-   * statistics are included (which is the case with recent Tor
-   * versions).
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV2Reqs();
-
-  /**
-   * Return statistics on directory requests for v3 network status
-   * consensuses of any flavor with map keys being country codes and map
-   * values being request numbers rounded up to the nearest multiple of 8,
-   * or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV3Reqs();
-
-  /**
-   * Return the share of requests for v2 network statuses that the server
-   * expects to receive from clients, or -1.0 if this share is not
-   * included (which is the case with recent Tor versions).
-   *
-   * @since 1.0.0
-   */
-  public double getDirreqV2Share();
-
-  /**
-   * Return the share of requests for v3 network status consensuses of
-   * any flavor that the server expects to receive from clients, or -1.0
-   * if this share is not included (which is the case with recent Tor
-   * versions).
-   *
-   * @since 1.0.0
-   */
-  public double getDirreqV3Share();
-
-  /**
-   * Return statistics on responses to directory requests for v2 network
-   * statuses with map keys being response strings and map values being
-   * response numbers rounded up to the nearest multiple of 4, or null if
-   * no such statistics are included (which is the case with recent Tor
-   * versions).
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV2Resp();
-
-  /**
-   * Return statistics on responses to directory requests for v3 network
-   * status consensuses of any flavor with map keys being response strings
-   * and map values being response numbers rounded up to the nearest
-   * multiple of 4, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV3Resp();
-
-  /**
-   * Return statistics on directory requests for v2 network statuses to
-   * the server's directory port with map keys being statistic keys and
-   * map values being statistic values like counts or quantiles, or null
-   * if no such statistics are included (which is the case with recent Tor
-   * versions).
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV2DirectDl();
-
-  /**
-   * Return statistics on directory requests for v3 network status
-   * consensuses of any flavor to the server's directory port with map
-   * keys being statistic keys and map values being statistic values like
-   * counts or quantiles, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV3DirectDl();
-
-  /**
-   * Return statistics on directory requests for v2 network statuses
-   * tunneled through a circuit with map keys being statistic keys and map
-   * values being statistic values, or null if no such statistics are
-   * included (which is the case with recent Tor versions).
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV2TunneledDl();
-
-  /**
-   * Return statistics on directory requests for v3 network status
-   * consensuses of any flavor tunneled through a circuit with map keys
-   * being statistic keys and map values being statistic values, or null
-   * if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getDirreqV3TunneledDl();
-
-  /**
-   * Return the directory request read history contained in this
-   * descriptor, or null if no such history is contained.
-   *
-   * @since 1.0.0
-   */
-  public BandwidthHistory getDirreqReadHistory();
-
-  /**
-   * Return the directory request write history contained in this
-   * descriptor, or null if no such history is contained.
-   *
-   * @since 1.0.0
-   */
-  public BandwidthHistory getDirreqWriteHistory();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * entry statistics interval ended, or -1 if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public long getEntryStatsEndMillis();
-
-  /**
-   * Return the interval length of the included entry statistics in
-   * seconds, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getEntryStatsIntervalLength();
-
-  /**
-   * Return statistics on client IP addresses with map keys being country
-   * codes and map values being the number of unique IP addresses that
-   * have connected from that country rounded up to the nearest multiple
-   * of 8, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getEntryIps();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * cell statistics interval ended, or -1 if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public long getCellStatsEndMillis();
-
-  /**
-   * Return the interval length of the included cell statistics in
-   * seconds, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getCellStatsIntervalLength();
-
-  /**
-   * Return the mean number of processed cells per circuit by circuit
-   * decile starting with the loudest decile at index 0 and the quietest
-   * decile at index 8, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public List<Integer> getCellProcessedCells();
-
-  /**
-   * Return the mean number of cells contained in circuit queues by
-   * circuit decile starting with the loudest decile at index 0 and the
-   * quietest decile at index 8, or null if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public List<Double> getCellQueuedCells();
-
-  /**
-   * Return the mean times in milliseconds that cells spend in circuit
-   * queues by circuit decile starting with the loudest decile at index 0
-   * and the quietest decile at index 8, or null if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public List<Integer> getCellTimeInQueue();
-
-  /**
-   * Return the mean number of circuits included in any of the cell
-   * statistics deciles, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public int getCellCircuitsPerDecile();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * statistics on bi-directional connection usage ended, or -1 if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getConnBiDirectStatsEndMillis();
-
-  /**
-   * Return the interval length of the included statistics on
-   * bi-directional connection usage in seconds, or -1 if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getConnBiDirectStatsIntervalLength();
-
-  /**
-   * Return the number of connections on which this server read and wrote
-   * less than 2 KiB/s in a 10-second interval, or -1 if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public int getConnBiDirectBelow();
-
-  /**
-   * Return the number of connections on which this server read and wrote
-   * at least 2 KiB/s in a 10-second interval and at least 10 times more
-   * in read direction than in write direction, or -1 if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public int getConnBiDirectRead();
-
-  /**
-   * Return the number of connections on which this server read and wrote
-   * at least 2 KiB/s in a 10-second interval and at least 10 times more
-   * in write direction than in read direction, or -1 if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public int getConnBiDirectWrite();
-
-  /**
-   * Return the number of connections on which this server read and wrote
-   * at least 2 KiB/s in a 10-second interval but not 10 times more in
-   * either direction, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public int getConnBiDirectBoth();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * exit statistics interval ended, or -1 if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public long getExitStatsEndMillis();
-
-  /**
-   * Return the interval length of the included exit statistics in
-   * seconds, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getExitStatsIntervalLength();
-
-  /**
-   * Return statistics on KiB written to streams exiting the Tor network
-   * by target TCP port with map keys being string representations of
-   * ports (or {@code "other"}) and map values being KiB rounded up to the
-   * next full KiB, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Long> getExitKibibytesWritten();
-
-  /**
-   * Return statistics on KiB read from streams exiting the Tor network
-   * by target TCP port with map keys being string representations of
-   * ports (or {@code "other"}) and map values being KiB rounded up to the
-   * next full KiB, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Long> getExitKibibytesRead();
-
-  /**
-   * Return statistics on opened streams exiting the Tor network by
-   * target TCP port with map keys being string representations of ports
-   * (or {@code "other"}) and map values being the number of opened
-   * streams, rounded up to the nearest multiple of 4, or null if no such
-   * statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Long> getExitStreamsOpened();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * "geoip" statistics interval started, or -1 if no such statistics are
-   * included (which is the case except for very old Tor versions).
-   *
-   * @since 1.0.0
-   */
-  public long getGeoipStartTimeMillis();
-
-  /**
-   * Return statistics on the origin of client IP addresses with map keys
-   * being country codes and map values being the number of unique IP
-   * addresses that have connected from that country between the start of
-   * the statistics interval and the descriptor publication time rounded
-   * up to the nearest multiple of 8, or null if no such statistics are
-   * included (which is the case except for very old Tor versions).
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getGeoipClientOrigins();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * bridge statistics interval ended, or -1 if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public long getBridgeStatsEndMillis();
-
-  /**
-   * Return the interval length of the included bridge statistics in
-   * seconds, or -1 if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public long getBridgeStatsIntervalLength();
-
-  /**
-   * Return statistics on bridge client IP addresses by country with map
-   * keys being country codes and map values being the number of unique IP
-   * addresses that have connected from that country rounded up to the
-   * nearest multiple of 8, or null if no such statistics are included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getBridgeIps();
-
-  /**
-   * Return statistics on bridge client IP addresses by IP version with
-   * map keys being protocol families, e.g., {@code "v4"} or {@code "v6"},
-   * and map values being the number of unique IP addresses rounded up to
-   * the nearest multiple of 8, or null if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getBridgeIpVersions();
-
-  /**
-   * Return statistics on bridge client IP addresses by transport with
-   * map keys being pluggable transport names, e.g., {@code "obfs2"} or
-   * {@code "obfs3"} for known transports, {@code "<OR>"} for the default
-   * onion routing protocol, or {@code "<??>"} for an unknown transport,
-   * and map values being the number of unique IP addresses rounded up to
-   * the nearest multiple of 8, or null if no such statistics are
-   * included.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getBridgeIpTransports();
-
-  /**
-   * Return the (possibly empty) list of pluggable transports supported
-   * by this server.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getTransports();
-
-  /**
-   * Return the time in milliseconds since the epoch when the included
-   * hidden-service statistics interval ended, or -1 if no such statistics
-   * are included.
-   *
-   * @since 1.1.0
-   */
-  public long getHidservStatsEndMillis();
-
-  /**
-   * Return the interval length of the included hidden-service statistics
-   * in seconds, or -1 if no such statistics are included.
-   *
-   * @since 1.1.0
-   */
-  public long getHidservStatsIntervalLength();
-
-  /**
-   * Return the approximate number of RELAY cells seen in either
-   * direction on a circuit after receiving and successfully processing a
-   * RENDEZVOUS1 cell, or null if no such statistics are included.
-   *
-   * @since 1.1.0
-   */
-  public Double getHidservRendRelayedCells();
-
-  /**
-   * Return the obfuscation parameters applied to the original
-   * measurement value of RELAY cells seen in either direction on a
-   * circuit after receiving and successfully processing a RENDEZVOUS1
-   * cell, or null if no such statistics are included.
-   *
-   * @since 1.1.0
-   */
-  public Map<String, Double> getHidservRendRelayedCellsParameters();
-
-  /**
-   * Return the approximate number of unique hidden-service identities
-   * seen in descriptors published to and accepted by this hidden-service
-   * directory, or null if no such statistics are included.
-   *
-   * @since 1.1.0
-   */
-  public Double getHidservDirOnionsSeen();
-
-  /**
-   * Return the obfuscation parameters applied to the original
-   * measurement value of unique hidden-service identities seen in
-   * descriptors published to and accepted by this hidden-service
-   * directory, or null if no such statistics are included.
-   *
-   * @since 1.1.0
-   */
-  public Map<String, Double> getHidservDirOnionsSeenParameters();
-
-  /**
-   * Return the RSA-1024 signature of the PKCS1-padded descriptor digest,
-   * taken from the beginning of the router line through the newline after
-   * the router-signature line, or null if the descriptor doesn't contain
-   * a signature (which is the case in sanitized bridge descriptors).
-   *
-   * @since 1.1.0
-   */
-  public String getRouterSignature();
-
-  /**
-   * Return the Ed25519 certificate in PEM format, or null if the
-   * descriptor doesn't contain one.
-   *
-   * @since 1.1.0
-   */
-  public String getIdentityEd25519();
-
-  /**
-   * Return the Ed25519 master key, encoded as 43 base64 characters
-   * without padding characters, which was either parsed from the optional
-   * {@code "master-key-ed25519"} line or derived from the (likewise
-   * optional) Ed25519 certificate following the
-   * {@code "identity-ed25519"} line, or null if the descriptor contains
-   * neither Ed25519 master key nor Ed25519 certificate.
-   *
-   * @since 1.1.0
-   */
-  public String getMasterKeyEd25519();
-
-  /**
-   * Return the Ed25519 signature of the SHA-256 digest of the entire
-   * descriptor, encoded as 86 base64 characters without padding
-   * characters, from the first character up to and including the first
-   * space after the {@code "router-sig-ed25519"} string, prefixed with
-   * the string {@code "Tor router descriptor signature v1"}.
-   *
-   * @since 1.1.0
-   */
-  public String getRouterSignatureEd25519();
-}
-
diff --git a/src/org/torproject/descriptor/ImplementationNotAccessibleException.java b/src/org/torproject/descriptor/ImplementationNotAccessibleException.java
deleted file mode 100644
index c54e48f..0000000
--- a/src/org/torproject/descriptor/ImplementationNotAccessibleException.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/* Copyright 2014--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Thrown if a descriptor source implementation class cannot be found,
- * instantiated, or accessed.
- *
- * @see DescriptorSourceFactory
- * @since 1.0.0
- */
-@SuppressWarnings("serial")
-public class ImplementationNotAccessibleException
-    extends RuntimeException {
-
-  public ImplementationNotAccessibleException(String string,
-      Throwable ex) {
-    super(string, ex);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/Microdescriptor.java b/src/org/torproject/descriptor/Microdescriptor.java
deleted file mode 100644
index f19b7df..0000000
--- a/src/org/torproject/descriptor/Microdescriptor.java
+++ /dev/null
@@ -1,135 +0,0 @@
-/* Copyright 2014--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-
-/**
- * Contains a relay microdescriptor.
- *
- * <p>A microdescriptor is a stripped-down version of a relay server
- * descriptor ({@link RelayServerDescriptor}) generated by the directory
- * authorities by extracting and/or transforming relay server descriptor
- * contents following strict rules without adding the authority's opinion
- * about the relay.  Microdescriptors are referenced from microdescriptor
- * consensuses ({@link RelayNetworkStatusConsensus}) and downloaded by
- * clients to make path-selection decisions and to build circuits.
- * Microdescriptors contain only the most relevant parts that clients care
- * about.  Microdescriptors are expected to be relatively static and only
- * change about once per week.</p>
- *
- * @since 1.0.0
- */
-public interface Microdescriptor extends Descriptor {
-
-  /**
-   * Return the SHA-256 descriptor digest, encoded as 43 base64
-   * characters without padding characters, that is used to reference this
-   * descriptor from a vote or microdescriptor consensus.
-   *
-   * @since 1.0.0
-   */
-  public String getMicrodescriptorDigest();
-
-  /**
-   * Return the RSA-1024 public key in PEM format used to encrypt CREATE
-   * cells for this server, or null if the descriptor doesn't contain an
-   * onion key.
-   *
-   * @since 1.0.0
-   */
-  public String getOnionKey();
-
-  /**
-   * Return the curve25519 public key, encoded as 43 base64 characters
-   * without padding characters, that is used for the ntor circuit
-   * extended handshake, or null if the descriptor didn't contain an
-   * ntor-onion-key line.
-   *
-   * @since 1.0.0
-   */
-  public String getNtorOnionKey();
-
-  /**
-   * Return IP addresses and TCP ports where this server accepts TLS
-   * connections for the main OR protocol, or an empty list if the server
-   * does not support additional addresses or ports; entries are given in
-   * the order as they are listed in the descriptor; IPv4 addresses are
-   * given in dotted-quad format, IPv6 addresses use the colon-separated
-   * hexadecimal format surrounded by square brackets, and TCP ports are
-   * separated from the IP address using a colon.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getOrAddresses();
-
-  /**
-   * Return nicknames, $-prefixed identity fingerprints, or tuples of the
-   * format {@code $fingerprint=nickname} or {@code $fingerprint~nickname}
-   * of servers contained in this server's family, or null if the
-   * descriptor does not contain a family line.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getFamilyEntries();
-
-  /**
-   * Return the default policy, {@code "accept"} or {@code "reject"}, of
-   * the IPv4 port summary, or null if the descriptor didn't contain an
-   * IPv4 exit-policy summary line which is equivalent to rejecting all
-   * streams to IPv4 targets.
-   *
-   * @since 1.0.0
-   */
-  public String getDefaultPolicy();
-
-  /**
-   * Return the port list of the IPv4 exit-policy summary, or null if the
-   * descriptor didn't contain an IPv4 exit-policy summary line which is
-   * equivalent to rejecting all streams to IPv4 targets.
-   *
-   * @since 1.0.0
-   */
-  public String getPortList();
-
-  /**
-   * Return the default policy, {@code "accept"} or {@code "reject"}, of
-   * the IPv6 port summary, or null if the descriptor didn't contain an
-   * IPv6 exit-policy summary line which is equivalent to rejecting all
-   * streams to IPv6 targets.
-   *
-   * @since 1.0.0
-   */
-  public String getIpv6DefaultPolicy();
-
-  /**
-   * Return the port list of the IPv6 exit-policy summary, or null if the
-   * descriptor didn't contain an IPv6 exit-policy summary line which is
-   * equivalent to rejecting all streams to IPv6 targets.
-   *
-   * @since 1.0.0
-   */
-  public String getIpv6PortList();
-
-  /**
-   * Return a SHA-1 digest of the server's RSA-1024 identity key, encoded
-   * as 27 base64 characters without padding characters, that is only
-   * included to prevent collisions between microdescriptors, or null if
-   * no such digest is included.
-   *
-   * @since 1.1.0
-   */
-  public String getRsa1024Identity();
-
-  /**
-   * Return a SHA-256 digest of the server's Ed25519 identity key,
-   * encoded as 43 base64 characters without padding characters, that is
-   * only included to prevent collisions between microdescriptors, or null
-   * if no such digest is included.
-   *
-   * @since 1.1.0
-   */
-  public String getEd25519Identity();
-}
-
diff --git a/src/org/torproject/descriptor/NetworkStatusEntry.java b/src/org/torproject/descriptor/NetworkStatusEntry.java
deleted file mode 100644
index 43b3175..0000000
--- a/src/org/torproject/descriptor/NetworkStatusEntry.java
+++ /dev/null
@@ -1,177 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-import java.util.Set;
-import java.util.SortedSet;
-
-/**
- * Contains an entry in a network status in the version 2 or 3 directory
- * protocol or in a bridge network status.
- *
- * <p>A network status entry is not a descriptor type of its own but is
- * part of a network status in the version 2 directory protocol
- * ({@link RelayNetworkStatus}), a vote ({@link RelayNetworkStatusVote})
- * or flavored/unflavored consensus (@link RelayNetworkStatusConsensus})
- * in the version 3 directory protocol, or a bridge network status
- * ({@link BridgeNetworkStatus}).  Entries in signed directories in the
- * version 1 directory protocol are represented by router status entries
- * ({@link RouterStatusEntry}).</p>
- *
- * @since 1.0.0
- */
-public interface NetworkStatusEntry {
-
-  /**
-   * Return the raw network status entry bytes.
-   *
-   * @since 1.0.0
-   */
-  public byte[] getStatusEntryBytes();
-
-  /**
-   * Return the server nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return a SHA-1 digest of the server's identity key, encoded as 40
-   * upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return the SHA-1 digest of the server descriptor, or null if the
-   * containing network status does not contain server descriptor
-   * references, like a microdesc consensus.
-   *
-   * @since 1.0.0
-   */
-  public String getDescriptor();
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the server's primary IPv4 address in dotted-quad format.
-   *
-   * @since 1.0.0
-   */
-  public String getAddress();
-
-  /**
-   * Return the TCP port where this server accepts TLS connections for
-   * the main OR protocol.
-   *
-   * @since 1.0.0
-   */
-  public int getOrPort();
-
-  /**
-   * Return the TCP port where this server accepts directory-related HTTP
-   * connections.
-   *
-   * @since 1.0.0
-   */
-  public int getDirPort();
-
-  /**
-   * Return the (possibly empty) set of microdescriptor digests if the
-   * containing network status is a vote or microdesc consensus, or null
-   * otherwise.
-   *
-   * @since 1.0.0
-   */
-  public Set<String> getMicrodescriptorDigests();
-
-  /**
-   * Return additional IP addresses and TCP ports where this server
-   * accepts TLS connections for the main OR protocol, or an empty list if
-   * the network status doesn't contain any such additional addresses and
-   * ports.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getOrAddresses();
-
-  /**
-   * Return the relay flags assigned to this server, or null if the
-   * status entry didn't contain any relay flags.
-   *
-   * @since 1.0.0
-   */
-  public SortedSet<String> getFlags();
-
-  /**
-   * Return the Tor software version, or null if the status entry didn't
-   * contain version information.
-   *
-   * @since 1.0.0
-   */
-  public String getVersion();
-
-  /**
-   * Return the bandwidth weight of this server or -1 if the status entry
-   * didn't contain a bandwidth line.
-   *
-   * @since 1.0.0
-   */
-  public long getBandwidth();
-
-  /**
-   * Return the measured bandwidth or -1 if the status entry either
-   * didn't contain bandwidth information or didn't contain an indication
-   * that this information is based on measured bandwidth.
-   *
-   * @since 1.0.0
-   */
-  public long getMeasured();
-
-  /**
-   * Return whether the status entry is yet unmeasured by the bandwidth
-   * authorities; only included in consensuses using method 17 or higher.
-   *
-   * @since 1.0.0
-   */
-  public boolean getUnmeasured();
-
-  /**
-   * Return the default policy of the port summary, which can be either
-   * {@code "accept"} or {@code "reject"}, or null if the status entry
-   * didn't contain an exit policy summary.
-   *
-   * @since 1.0.0
-   */
-  public String getDefaultPolicy();
-
-  /**
-   * Return the list of ports or port intervals of the exit port summary,
-   * or null if the status entry didn't contain an exit policy summary.
-   *
-   * @since 1.0.0
-   */
-  public String getPortList();
-
-  /**
-   * Return the server's Ed25519 master key, encoded as 43 base64
-   * characters without padding characters, "none" if the relay doesn't
-   * have an Ed25519 identity, or null if the status entry didn't contain
-   * this information or if the status is not a vote.
-   *
-   * @since 1.1.0
-   */
-  public String getMasterKeyEd25519();
-}
-
diff --git a/src/org/torproject/descriptor/RelayDirectory.java b/src/org/torproject/descriptor/RelayDirectory.java
deleted file mode 100644
index 8f3e58b..0000000
--- a/src/org/torproject/descriptor/RelayDirectory.java
+++ /dev/null
@@ -1,104 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-
-/**
- * Contains a signed directory in the version 1 directory protocol.
- *
- * <p>Directory authorities in the (long outdated) version 1 of the
- * directory protocol served signed directory documents containing a list
- * of signed server descriptors ({@link ServerDescriptor}) along with
- * short summaries of the status of each server
- * ({@link RouterStatusEntry}).</p>
- *
- * <p>Clients in that version of the directory protocol would fetch this
- * signed directory to get up-to-date information on the state of the
- * network and be certain that the list was attested by a trusted
- * directory authority.</p>
- *
- * <p>Signed directories in the version 1 directory protocol have first
- * been superseded by network status documents in the version 2 directory
- * protocol ({@link RelayNetworkStatus}) and later by network status
- * consensuses ({@link RelayNetworkStatusConsensus}) in the version 3
- * directory protocol.</p> 
- *
- * @since 1.0.0
- */
-public interface RelayDirectory extends Descriptor {
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the RSA-1024 public key in PEM format used by this authority
-   * as long-term identity key and to sign network statuses, or null if
-   * this key is not included in the descriptor header.
-   *
-   * @since 1.0.0
-   */
-  public String getDirSigningKey();
-
-  /**
-   * Return recommended Tor versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedSoftware();
-
-  /**
-   * Return the directory signature string made with the authority's
-   * identity key.
-   *
-   * @since 1.0.0
-   */
-  public String getDirectorySignature();
-
-  /**
-   * Return router status entries, one for each contained relay.
-   *
-   * @since 1.0.0
-   */
-  public List<RouterStatusEntry> getRouterStatusEntries();
-
-  /**
-   * Return a list of server descriptors contained in the signed
-   * directory.
-   *
-   * @since 1.0.0
-   */
-  public List<ServerDescriptor> getServerDescriptors();
-
-  /**
-   * Return a (very likely empty) list of exceptions from parsing the
-   * contained server descriptors.
-   *
-   * @since 1.0.0
-   */
-  public List<Exception> getServerDescriptorParseExceptions();
-
-  /**
-   * Return the directory nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return the SHA-1 directory digest, encoded as 40 lower-case
-   * hexadecimal characters, that the directory authority used to sign the
-   * directory.
-   *
-   * @since 1.0.0
-   */
-  public String getDirectoryDigest();
-}
-
diff --git a/src/org/torproject/descriptor/RelayExtraInfoDescriptor.java b/src/org/torproject/descriptor/RelayExtraInfoDescriptor.java
deleted file mode 100644
index 73f8438..0000000
--- a/src/org/torproject/descriptor/RelayExtraInfoDescriptor.java
+++ /dev/null
@@ -1,21 +0,0 @@
-/* Copyright 2015--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains a relay extra-info descriptor.
- *
- * <p>Relay extra-info descriptors share many contents with sanitized
- * bridge extra-info descriptors ({@link BridgeExtraInfoDescriptor}),
- * which is why they share a common superinterface
- * ({@link ExtraInfoDescriptor}).  The main purpose of having two
- * subinterfaces is being able to distinguish descriptor types more
- * easily.</p>
- *
- * @since 1.1.0
- */
-public interface RelayExtraInfoDescriptor extends ExtraInfoDescriptor {
-
-}
-
diff --git a/src/org/torproject/descriptor/RelayNetworkStatus.java b/src/org/torproject/descriptor/RelayNetworkStatus.java
deleted file mode 100644
index db3ddac..0000000
--- a/src/org/torproject/descriptor/RelayNetworkStatus.java
+++ /dev/null
@@ -1,176 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-import java.util.SortedMap;
-import java.util.SortedSet;
-
-/**
- * Contains a network status document in the version 2 directory protocol.
- *
- * <p>Directory authorities in the (outdated) version 2 of the directory
- * protocol published signed network status documents.  Each network
- * status listed, for every relay in the network
- * ({@link NetworkStatusEntry}): a hash of its identity key, a hash of its
- * most recent server descriptor, and a summary of what the authority
- * believed about its status.</p>
- *
- * <p>Clients would download the authorities' network status documents in
- * turn, and believe statements about routers iff they were attested to by
- * more than half of the authorities.</p>
- *
- * <p>Network status documents in the version 2 directory protocol
- * supersede signed directories in the version 1 directory protocol
- * ({@link RelayDirectory}) and have been superseded by network status
- * consensuses ({@link RelayNetworkStatusConsensus}) in the version 3
- * directory protocol.</p>
- *
- * @since 1.0.0
- */
-public interface RelayNetworkStatus extends Descriptor {
-
-  /**
-   * Return the document format version of this descriptor which is 2.
-   *
-   * @since 1.0.0
-   */
-  public int getNetworkStatusVersion();
-
-  /**
-   * Return the authority's hostname.
-   *
-   * @since 1.0.0
-   */
-  public String getHostname();
-
-  /**
-   * Return the authority's primary IPv4 address in dotted-quad format,
-   * or null if the descriptor does not contain an address.
-   *
-   * @since 1.0.0
-   */
-  public String getAddress();
-
-  /**
-   * Return the TCP port where this authority accepts directory-related
-   * HTTP connections, or 0 if the authority does not accept such
-   * connections.
-   *
-   * @since 1.0.0
-   */
-  public int getDirport();
-
-  /**
-   * Return a SHA-1 digest of the authority's public identity key,
-   * encoded as 40 upper-case hexadecimal characters, which is also used
-   * to sign network statuses.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return the contact information for this authority, which may contain
-   * non-ASCII characters.
-   *
-   * @since 1.0.0
-   */
-  public String getContactLine();
-
-  /**
-   * Return the RSA-1024 public key in PEM format used by this authority
-   * as long-term identity key and to sign network statuses.
-   *
-   * @since 1.0.0
-   */
-  public String getDirSigningKey();
-
-  /**
-   * Return recommended Tor versions for server usage, or null if the
-   * authority does not recommend server versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedServerVersions();
-
-  /**
-   * Return recommended Tor versions for client usage, or null if the
-   * authority does not recommend client versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedClientVersions();
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the set of flags that this directory assigns to relays, or
-   * null if the status does not assign such flags.
-   *
-   * @since 1.0.0
-   */
-  public SortedSet<String> getDirOptions();
-
-  /**
-   * Return status entries for each contained server, with map keys being
-   * SHA-1 digests of the servers' public identity keys, encoded as 40
-   * upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
-
-  /**
-   * Return whether a status entry with the given relay fingerprint
-   * (SHA-1 digest of the server's public identity key, encoded as 40
-   * upper-case hexadecimal characters) exists; convenience method for
-   * {@code getStatusEntries().containsKey(fingerprint)}.
-   *
-   * @since 1.0.0
-   */
-  public boolean containsStatusEntry(String fingerprint);
-
-  /**
-   * Return a status entry by relay fingerprint (SHA-1 digest of the
-   * server's public identity key, encoded as 40 upper-case hexadecimal
-   * characters), or null if no such status entry exists; convenience
-   * method for {@code getStatusEntries().get(fingerprint)}.
-   *
-   * @since 1.0.0
-   */
-  public NetworkStatusEntry getStatusEntry(String fingerprint);
-
-  /**
-   * Return the authority's nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return the directory signature string made with the authority's
-   * identity key.
-   *
-   * @since 1.0.0
-   */
-  public String getDirectorySignature();
-
-  /**
-   * Return the SHA-1 status digest, encoded as 40 lower-case hexadecimal
-   * characters, that the directory authority used to sign the network
-   * status.
-   *
-   * @since 1.0.0
-   */
-  public String getStatusDigest();
-}
-
diff --git a/src/org/torproject/descriptor/RelayNetworkStatusConsensus.java b/src/org/torproject/descriptor/RelayNetworkStatusConsensus.java
deleted file mode 100644
index 15fdaca..0000000
--- a/src/org/torproject/descriptor/RelayNetworkStatusConsensus.java
+++ /dev/null
@@ -1,223 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-import java.util.SortedMap;
-import java.util.SortedSet;
-
-/**
- * Contains a network status consensus in the version 3 directory protocol.
- *
- * <p>Directory authorities in the version 3 of the directory protocol
- * periodically generate a view of the current descriptors and status for
- * known relays and send a signed summary of this view to the other
- * authorities ({@link RelayNetworkStatusVote}).  The authorities compute
- * the result of this vote and sign a network status consensus containing
- * the result of the vote, which is this document.</p>
- *
- * <p>Clients use consensus documents to find out when their list of
- * relays is out-of-date by looking at the contained network status
- * entries ({@link NetworkStatusEntry}).  If it is, they download any
- * missing server descriptors ({@link ServerDescriptor}).</p>
- *
- * @since 1.0.0
- */
-public interface RelayNetworkStatusConsensus extends Descriptor {
-
-  /**
-   * Return the document format version of this descriptor which is 3 or
-   * higher.
-   *
-   * @since 1.0.0
-   */
-  public int getNetworkStatusVersion();
-
-  /**
-   * Return the consensus flavor name, which denotes the variant of the
-   * original, unflavored consensus, encoded as a string of alphanumeric
-   * characters and dashes, or null if this descriptor is the unflavored
-   * consensus.
-   *
-   * @since 1.0.0
-   */
-  public String getConsensusFlavor();
-
-  /**
-   * Return the consensus method number of this descriptor, which is the
-   * highest consensus method supported by more than 2/3 of voting
-   * authorities, or 0 if no consensus method is contained in the
-   * descriptor.
-   *
-   * @since 1.0.0
-   */
-  public int getConsensusMethod();
-
-  /**
-   * Return the time in milliseconds since the epoch at which this
-   * descriptor became valid.
-   *
-   * @since 1.0.0
-   */
-  public long getValidAfterMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch until which this
-   * descriptor is the freshest that is available.
-   *
-   * @since 1.0.0
-   */
-  public long getFreshUntilMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch until which this
-   * descriptor was valid.
-   *
-   * @since 1.0.0
-   */
-  public long getValidUntilMillis();
-
-  /**
-   * Return the number of seconds that the directory authorities will
-   * allow to collect votes from the other authorities when producing the
-   * next consensus.
-   *
-   * @since 1.0.0
-   */
-  public long getVoteSeconds();
-
-  /**
-   * Return the number of seconds that the directory authorities will
-   * allow to collect signatures from the other authorities when producing
-   * the next consensus.
-   *
-   * @since 1.0.0
-   */
-  public long getDistSeconds();
-
-  /**
-   * Return recommended Tor versions for server usage, or null if the
-   * consensus does not contain an opinion about server versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedServerVersions();
-
-  /**
-   * Return recommended Tor versions for client usage, or null if the
-   * consensus does not contain an opinion about client versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedClientVersions();
-
-  /**
-   * Return a list of software packages and their versions together with a
-   * URL and one or more digests in the format <code>PackageName Version
-   * URL DIGESTS</code> that are known by at least three directory
-   * authorities and agreed upon by the majority of directory authorities,
-   * or null if the consensus does not contain package information.
-   *
-   * @since 1.3.0
-   */
-  public List<String> getPackageLines();
-
-  /**
-   * Return known relay flags in this descriptor that were contained in
-   * enough votes for this consensus to be an authoritative opinion for
-   * these relay flags.
-   *
-   * @since 1.0.0
-   */
-  public SortedSet<String> getKnownFlags();
-
-  /**
-   * Return consensus parameters contained in this descriptor with map
-   * keys being case-sensitive parameter identifiers and map values being
-   * parameter values, or null if the consensus doesn't contain consensus
-   * parameters.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getConsensusParams();
-
-  /**
-   * Return directory source entries for each directory authority that
-   * contributed to the consensus, with map keys being SHA-1 digests of
-   * the authorities' identity keys in the version 3 directory protocol,
-   * encoded as 40 upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, DirSourceEntry> getDirSourceEntries();
-
-  /**
-   * Return status entries for each contained server, with map keys being
-   * SHA-1 digests of the servers' public identity keys, encoded as 40
-   * upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
-
-  /**
-   * Return whether a status entry with the given relay fingerprint
-   * (SHA-1 digest of the server's public identity key, encoded as 40
-   * upper-case hexadecimal characters) exists; convenience method for
-   * {@code getStatusEntries().containsKey(fingerprint)}.
-   *
-   * @since 1.0.0
-   */
-  public boolean containsStatusEntry(String fingerprint);
-
-  /**
-   * Return a status entry by relay fingerprint (SHA-1 digest of the
-   * server's public identity key, encoded as 40 upper-case hexadecimal
-   * characters), or null if no such status entry exists; convenience
-   * method for {@code getStatusEntries().get(fingerprint)}.
-   *
-   * @since 1.0.0
-   */
-  public NetworkStatusEntry getStatusEntry(String fingerprint);
-
-  /**
-   * Return directory signatures of this consensus, with map keys being
-   * SHA-1 digests of the authorities' identity keys in the version 3
-   * directory protocol, encoded as 40 upper-case hexadecimal characters.
-   *
-   * @deprecated Replaced by {@link #getSignatures()} which permits an
-   * arbitrary number of signatures made by an authority using the same
-   * identity key digest and different algorithms.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, DirectorySignature> getDirectorySignatures();
-
-  /**
-   * Return the list of signatures contained in this consensus.
-   *
-   * @since 1.3.0
-   */
-  public List<DirectorySignature> getSignatures();
-
-  /**
-   * Return optional weights to be applied to router bandwidths during
-   * path selection with map keys being case-sensitive weight identifiers
-   * and map values being weight values, or null if the consensus doesn't
-   * contain such weights.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getBandwidthWeights();
-
-  /**
-   * Return the SHA-1 digest of this consensus, encoded as 40 upper-case
-   * hexadecimal characters that directory authorities use to sign the
-   * consensus.
-   *
-   * @since 1.0.0
-   */
-  public String getConsensusDigest();
-}
-
diff --git a/src/org/torproject/descriptor/RelayNetworkStatusVote.java b/src/org/torproject/descriptor/RelayNetworkStatusVote.java
deleted file mode 100644
index 1f77db6..0000000
--- a/src/org/torproject/descriptor/RelayNetworkStatusVote.java
+++ /dev/null
@@ -1,408 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-import java.util.SortedMap;
-import java.util.SortedSet;
-
-/**
- * Contains a network status vote in the version 3 directory protocol.
- *
- * <p>Directory authorities in the version 3 of the directory protocol
- * periodically generate a view of the current descriptors and status for
- * known relays and send a signed summary of this view to the other
- * authorities, which is this document.  The authorities compute the
- * result of this vote and sign a network status consensus containing the
- * result of the vote ({@link RelayNetworkStatusConsensus}).</p>
- *
- * @since 1.0.0
- */
-public interface RelayNetworkStatusVote extends Descriptor {
-
-  /**
-   * Return the document format version of this descriptor which is 3 or
-   * higher.
-   *
-   * @since 1.0.0
-   */
-  public int getNetworkStatusVersion();
-
-  /**
-   * Return the list of consensus method numbers supported by this
-   * authority, or null if the descriptor doesn't say so, which would mean
-   * that only method 1 is supported.
-   *
-   * @since 1.0.0
-   */
-  public List<Integer> getConsensusMethods();
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * was published.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch at which the
-   * consensus is supposed to become valid.
-   *
-   * @since 1.0.0
-   */
-  public long getValidAfterMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch until which the
-   * consensus is supposed to be the freshest that is available.
-   *
-   * @since 1.0.0
-   */
-  public long getFreshUntilMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch until which the
-   * consensus is supposed to be valid.
-   *
-   * @since 1.0.0
-   */
-  public long getValidUntilMillis();
-
-  /**
-   * Return the number of seconds that the directory authorities will
-   * allow to collect votes from the other authorities when producing the
-   * next consensus.
-   *
-   * @since 1.0.0
-   */
-  public long getVoteSeconds();
-
-  /**
-   * Return the number of seconds that the directory authorities will
-   * allow to collect signatures from the other authorities when producing
-   * the next consensus.
-   *
-   * @since 1.0.0
-   */
-  public long getDistSeconds();
-
-  /**
-   * Return recommended Tor versions for server usage, or null if the
-   * authority does not recommend server versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedServerVersions();
-
-  /**
-   * Return recommended Tor versions for client usage, or null if the
-   * authority does not recommend client versions.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getRecommendedClientVersions();
-
-  /**
-   * Return a list of software packages and their versions together with a
-   * URL and one or more digests in the format <code>PackageName Version
-   * URL DIGESTS</code> that are known by this directory authority, or
-   * null if this descriptor does not contain package information.
-   *
-   * @since 1.3.0
-   */
-  public List<String> getPackageLines();
-
-  /**
-   * Return known relay flags by this authority.
-   *
-   * @since 1.0.0
-   */
-  public SortedSet<String> getKnownFlags();
-
-  /**
-   * Return the minimum uptime in seconds that this authority requires
-   * for assigning the Stable flag, or -1 if the authority doesn't report
-   * this value.
-   *
-   * @since 1.0.0
-   */
-  public long getStableUptime();
-
-  /**
-   * Return the minimum MTBF (mean time between failure) that this
-   * authority requires for assigning the Stable flag, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.0.0
-   */
-  public long getStableMtbf();
-
-  /**
-   * Return the minimum bandwidth that this authority requires for
-   * assigning the Fast flag, or -1 if the authority doesn't report this
-   * value.
-   *
-   * @since 1.0.0
-   */
-  public long getFastBandwidth();
-
-  /**
-   * Return the minimum WFU (weighted fractional uptime) in percent that
-   * this authority requires for assigning the Guard flag, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.0.0
-   */
-  public double getGuardWfu();
-
-  /**
-   * Return the minimum weighted time in seconds that this authority
-   * needs to know about a relay before assigning the Guard flag, or -1 if
-   * the authority doesn't report this information.
-   *
-   * @since 1.0.0
-   */
-  public long getGuardTk();
-
-  /**
-   * Return the minimum bandwidth that this authority requires for
-   * assigning the Guard flag if exits can be guards, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.0.0
-   */
-  public long getGuardBandwidthIncludingExits();
-
-  /**
-   * Return the minimum bandwidth that this authority requires for
-   * assigning the Guard flag if exits can not be guards, or -1 if the
-   * authority doesn't report this value.
-   *
-   * @since 1.0.0
-   */
-  public long getGuardBandwidthExcludingExits();
-
-  /**
-   * Return 1 if the authority has measured enough MTBF info to use the
-   * MTBF requirement instead of the uptime requirement for assigning the
-   * Stable flag, 0 if not, or -1 if the authority doesn't report this
-   * information.
-   *
-   * @since 1.0.0
-   */
-  public int getEnoughMtbfInfo();
-
-  /**
-   * Return 1 if the authority has enough measured bandwidths that it'll
-   * ignore the advertised bandwidth claims of routers without measured
-   * bandwidth, 0 if not, or -1 if the authority doesn't report this
-   * information.
-   *
-   * @since 1.1.0
-   */
-  public int getIgnoringAdvertisedBws();
-
-  /**
-   * Return consensus parameters contained in this descriptor with map
-   * keys being case-sensitive parameter identifiers and map values being
-   * parameter values, or null if the authority doesn't include consensus
-   * parameters in its vote.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, Integer> getConsensusParams();
-
-  /**
-   * Return the authority's nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return a SHA-1 digest of the authority's long-term authority
-   * identity key used for the version 3 directory protocol, encoded as
-   * 40 upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getIdentity();
-
-  /**
-   * Return the authority's hostname.
-   *
-   * @since 1.2.0
-   */
-  public String getHostname();
-
-  /**
-   * Return the authority's primary IPv4 address in dotted-quad format,
-   * or null if the descriptor does not contain an address.
-   *
-   * @since 1.0.0
-   */
-  public String getAddress();
-
-  /**
-   * Return the TCP port where this authority accepts directory-related
-   * HTTP connections, or 0 if the authority does not accept such
-   * connections.
-   *
-   * @since 1.0.0
-   */
-  public int getDirport();
-
-  /**
-   * Return the TCP port where this authority accepts TLS connections for
-   * the main OR protocol, or 0 if the authority does not accept such
-   * connections.
-   *
-   * @since 1.0.0
-   */
-  public int getOrport();
-
-  /**
-   * Return the contact information for this authority, which may contain
-   * non-ASCII characters, or null if no contact information is included
-   * in the descriptor.
-   *
-   * @since 1.0.0
-   */
-  public String getContactLine();
-
-  /**
-   * Return the version of the directory key certificate used by this
-   * authority, which must be 3 or higher.
-   *
-   * @since 1.0.0
-   */
-  public int getDirKeyCertificateVersion();
-
-  /**
-   * Return the SHA-1 digest for an obsolete authority identity key still
-   * used by this authority to keep older clients working, or null if this
-   * authority does not use such a key.
-   *
-   * @since 1.0.0
-   */
-  public String getLegacyDirKey();
-
-  /**
-   * Return the authority's identity key in PEM format.
-   *
-   * @since 1.2.0
-   */
-  public String getDirIdentityKey();
-
-  /**
-   * Return the time in milliseconds since the epoch when the authority's
-   * signing key and corresponding key certificate were generated.
-   *
-   * @since 1.0.0
-   */
-  public long getDirKeyPublishedMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch after which the
-   * authority's signing key is no longer valid.
-   *
-   * @since 1.0.0
-   */
-  public long getDirKeyExpiresMillis();
-
-  /**
-   * Return the authority's signing key in PEM format.
-   *
-   * @since 1.2.0
-   */
-  public String getDirSigningKey();
-
-  /**
-   * Return the SHA-1 digest of the authority's signing key, encoded as
-   * 40 upper-case hexadecimal characters, or null if this digest cannot
-   * be obtained from the directory signature.
-   *
-   * @deprecated Removed in order to be more explicit that authorities may
-   *     use different digest algorithms than "sha1"; see
-   *     {@link #getSignatures()} and
-   *     {@link DirectorySignature#getSigningKeyDigest()} for
-   *     alternatives.
-   *
-   * @since 1.0.0
-   */
-  public String getSigningKeyDigest();
-
-  /**
-   * Return the signature of the authority's identity key made using the
-   * authority's signing key, or null if the vote does not contain such a
-   * signature.
-   *
-   * @since 1.2.0
-   */
-  public String getDirKeyCrosscert();
-
-  /**
-   * Return the certificate signature from the initial item
-   * "dir-key-certificate-version" until the final item
-   * "dir-key-certification", signed with the authority identity key.
-   *
-   * @since 1.2.0
-   */
-  public String getDirKeyCertification();
-
-  /**
-   * Return status entries for each contained server, with map keys being
-   * SHA-1 digests of the servers' public identity keys, encoded as 40
-   * upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, NetworkStatusEntry> getStatusEntries();
-
-  /**
-   * Return whether a status entry with the given relay fingerprint
-   * (SHA-1 digest of the server's public identity key, encoded as 40
-   * upper-case hexadecimal characters) exists; convenience method for
-   * {@code getStatusEntries().containsKey(fingerprint)}.
-   *
-   * @since 1.0.0
-   */
-  public boolean containsStatusEntry(String fingerprint);
-
-  /**
-   * Return a status entry by relay fingerprint (SHA-1 digest of the
-   * server's public identity key, encoded as 40 upper-case hexadecimal
-   * characters), or null if no such status entry exists; convenience
-   * method for {@code getStatusEntries().get(fingerprint)}.
-   *
-   * @since 1.0.0
-   */
-  public NetworkStatusEntry getStatusEntry(String fingerprint);
-
-  /**
-   * Return the directory signature of this vote, with the single map key
-   * being the SHA-1 digest of the authority's identity key in the version
-   * 3 directory protocol, encoded as 40 upper-case hexadecimal
-   * characters.
-   *
-   * @deprecated Replaced by {@link #getSignatures()} which permits an
-   * arbitrary number of signatures made by the authority using the same
-   * identity key digest and different algorithms.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<String, DirectorySignature> getDirectorySignatures();
-
-  /**
-   * Return a list of signatures contained in this vote, which is
-   * typically a single signature made by the authority but which may also
-   * be more than one signature made with different keys or algorithms.
-   *
-   * @since 1.3.0
-   */
-  public List<DirectorySignature> getSignatures();
-}
-
diff --git a/src/org/torproject/descriptor/RelayServerDescriptor.java b/src/org/torproject/descriptor/RelayServerDescriptor.java
deleted file mode 100644
index 6ef3140..0000000
--- a/src/org/torproject/descriptor/RelayServerDescriptor.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/* Copyright 2015--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains a relay server descriptor.
- *
- * <p>Relay server descriptors share many contents with sanitized bridge
- * server descriptors ({@link BridgeServerDescriptor}), which is why they
- * share a common superinterface ({@link ServerDescriptor}).  The main
- * purpose of having two subinterfaces is being able to distinguish
- * descriptor types more easily.</p>
- *
- * @since 1.1.0
- */
-public interface RelayServerDescriptor extends ServerDescriptor {
-
-}
-
diff --git a/src/org/torproject/descriptor/RouterStatusEntry.java b/src/org/torproject/descriptor/RouterStatusEntry.java
deleted file mode 100644
index f9a56db..0000000
--- a/src/org/torproject/descriptor/RouterStatusEntry.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-/**
- * Contains a router status entry contained in a signed directory in the
- * version 1 directory protocol.
- *
- * <p>Directory authorities in the (long outdated) version 1 of the
- * directory protocol included router status entries with short summaries
- * of the status of each server in the signed directories they produced
- * ({@link RelayDirectory}).  These entries contained references to server
- * descriptors published by relays together with the authorities' opinion
- * on whether relays were verified and live.</p>
- *
- * @since 1.0.0
- */
-public interface RouterStatusEntry {
-
-  /**
-   * Return the relay nickname consisting of 1 to 19 alphanumeric
-   * characters, or null if the relay is unverified.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return a SHA-1 digest of the relay's identity key, encoded as 40
-   * upper-case hexadecimal characters.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return whether the relay is verified.
-   *
-   * @since 1.0.0
-   */
-  public boolean isVerified();
-
-  /**
-   * Return whether the relay is live.
-   *
-   * @since 1.0.0
-   */
-  public boolean isLive();
-}
-
diff --git a/src/org/torproject/descriptor/ServerDescriptor.java b/src/org/torproject/descriptor/ServerDescriptor.java
deleted file mode 100644
index d1af421..0000000
--- a/src/org/torproject/descriptor/ServerDescriptor.java
+++ /dev/null
@@ -1,435 +0,0 @@
-/* Copyright 2011--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-
-/**
- * Contains a relay or sanitized bridge server descriptor.
- *
- * <p>Relays publish server descriptors to the directory authorities to
- * register in the network.  Server descriptors contain information about
- * the capabilities of a server, like their exit policy, that clients use
- * to select servers for their circuits (along with information provided
- * by directory authorities on reachability, stability, and capacity of
- * servers).  Server descriptors also contain network addresses and
- * cryptographic material that clients use to build circuits.</p>
- *
- * <p>Prior to the introduction of microdescriptors
- * ({@link Microdescriptor}), the directory authorities included
- * cryptographic digests of server descriptors in network statuses
- * ({@link RelayNetworkStatusConsensus}) and clients downloaded all
- * referenced server descriptors.  Nowadays, the directory authorities
- * derive microdescriptors from server descriptors and reference those
- * in network statuses, and clients only download microdescriptors instead
- * of server descriptors.</p>
- *
- * <p>Bridges publish server descriptors to the bridge directory
- * authority, also to announce themselves in the network.  The bridge
- * directory authority compiles a list of available bridges
- * ({@link BridgeNetworkStatus}) for the bridge distribution service
- * BridgeDB.  There are no microdescriptors for bridges, so that bridge
- * clients still rely on downloading bridge server descriptors directly
- * from the bridge they're connecting to.</p>
- *
- * <p>It's worth noting that all contents of server descriptors are
- * written and signed by relays and bridges without a third party
- * verifying their correctness.  The (bridge) directory authorities may
- * decide to exclude dishonest servers from the network statuses they
- * produce, but that wouldn't be reflected in server descriptors.</p>
- *
- * @since 1.0.0
- */
-public interface ServerDescriptor extends Descriptor {
-
-  /**
-   * Return the SHA-1 descriptor digest, encoded as 40 lower-case (relay
-   * descriptors) or upper-case (bridge descriptors) hexadecimal
-   * characters, that is used to reference this descriptor from a network
-   * status descriptor.
-   *
-   * @since 1.0.0
-   */
-  public String getServerDescriptorDigest();
-
-  /**
-   * Return the SHA-256 descriptor digest, encoded as 43 base64
-   * characters without padding characters, that may be used to reference
-   * this server descriptor from a network status descriptor.
-   *
-   * @since 1.1.0
-   */
-  public String getServerDescriptorDigestSha256();
-
-  /**
-   * Return the server's nickname consisting of 1 to 19 alphanumeric
-   * characters.
-   *
-   * @since 1.0.0
-   */
-  public String getNickname();
-
-  /**
-   * Return the server's primary IPv4 address in dotted-quad format.
-   *
-   * @since 1.0.0
-   */
-  public String getAddress();
-
-  /**
-   * Return the TCP port where this server accepts TLS connections for
-   * the main OR protocol, or 0 if the server does not accept such
-   * connections.
-   *
-   * @since 1.0.0
-   */
-  public int getOrPort();
-
-  /**
-   * Return the TCP port where this server accepts SOCKS connections,
-   * which is deprecated and should always be 0.
-   *
-   * @since 1.0.0
-   */
-  public int getSocksPort();
-
-  /**
-   * Return the TCP port where this server accepts directory-related HTTP
-   * connections, or 0 if the server does not accept such connections.
-   *
-   * @since 1.0.0
-   */
-  public int getDirPort();
-
-  /**
-   * Return IP addresses and TCP ports where this server accepts TLS
-   * connections for the main OR protocol, or an empty list if the server
-   * does not support additional addresses or ports; entries are given in
-   * the order as they are listed in the descriptor; IPv4 addresses are
-   * given in dotted-quad format, IPv6 addresses use the colon-separated
-   * hexadecimal format surrounded by square brackets, and TCP ports are
-   * separated from the IP address using a colon.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getOrAddresses();
-
-  /**
-   * Return the average bandwidth in bytes per second that the server is
-   * willing to sustain over long periods.
-   *
-   * @since 1.0.0
-   */
-  public int getBandwidthRate();
-
-  /**
-   * Return the burst bandwidth in bytes per second that the server is
-   * willing to sustain in very short intervals.
-   *
-   * @since 1.0.0
-   */
-  public int getBandwidthBurst();
-
-  /**
-   * Return the observed bandwidth in bytes per second as an estimate of
-   * the capacity that the server can handle, or -1 if the descriptor
-   * doesn't contain an observed bandwidth value (which is the case for
-   * Tor 0.0.8 or older).
-   *
-   * @since 1.0.0
-   */
-  public int getBandwidthObserved();
-
-  /**
-   * Return a human-readable string describing the Tor software version
-   * and the operating system of this server, which may contain non-ASCII
-   * characters, typically written as {@code "Tor $version on $system"},
-   * or null if this descriptor does not contain a platform line.
-   *
-   * @since 1.0.0
-   */
-  public String getPlatform();
-
-  /**
-   * Return the time in milliseconds since the epoch when this descriptor
-   * and the corresponding extra-info descriptor were generated.
-   *
-   * @since 1.0.0
-   */
-  public long getPublishedMillis();
-
-  /**
-   * Return a SHA-1 digest of the server's public identity key, encoded
-   * as 40 upper-case hexadecimal characters (without spaces after every 4
-   * characters as opposed to the encoding in the descriptor), that is
-   * typically used to uniquely identify the server, or null if this
-   * descriptor does not contain a fingerprint line.
-   *
-   * @since 1.0.0
-   */
-  public String getFingerprint();
-
-  /**
-   * Return whether the server was hibernating when this descriptor was
-   * published and should not be used to build circuits.
-   *
-   * @since 1.0.0
-   */
-  public boolean isHibernating();
-
-  /**
-   * Return the number of seconds that the server process has been
-   * running (which might even be negative in a few descriptors due to a
-   * bug that was fixed in Tor 0.1.2.7-alpha), or null if the descriptor
-   * does not contain an uptime line.
-   *
-   * @since 1.0.0
-   */
-  public Long getUptime();
-
-  /**
-   * Return the RSA-1024 public key in PEM format used to encrypt CREATE
-   * cells for this server, or null if the descriptor doesn't contain an
-   * onion key (which is the case in sanitized bridge descriptors).
-   *
-   * @since 1.0.0
-   */
-  public String getOnionKey();
-
-  /**
-   * Return the RSA-1024 public key in PEM format used by this server as
-   * long-term identity key, or null if the descriptor doesn't contain a
-   * signing key (which is the case in sanitized bridge descriptors).
-   *
-   * @since 1.0.0
-   */
-  public String getSigningKey();
-
-  /**
-   * Return the server's exit policy consisting of one or more accept or
-   * reject rules that the server follows when deciding whether to allow a
-   * new stream to a given IP address and TCP port.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getExitPolicyLines();
-
-  /**
-   * Return the RSA-1024 signature of the PKCS1-padded descriptor digest,
-   * taken from the beginning of the router line through the newline after
-   * the router-signature line, or null if the descriptor doesn't contain
-   * a signature (which is the case in sanitized bridge descriptors).
-   *
-   * @since 1.0.0
-   */
-  public String getRouterSignature();
-
-  /**
-   * Return the contact information for this server, which may contain
-   * non-ASCII characters, or null if no contact information is included
-   * in the descriptor.
-   *
-   * @since 1.0.0
-   */
-  public String getContact();
-
-  /**
-   * Return nicknames, $-prefixed identity fingerprints, or tuples of the
-   * format {@code $fingerprint=nickname} or {@code $fingerprint~nickname}
-   * of servers contained in this server's family, or null if the
-   * descriptor does not contain a family line.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getFamilyEntries();
-
-  /**
-   * Return the server's history of read bytes, or null if the descriptor
-   * does not contain a bandwidth history; current Tor versions include
-   * bandwidth histories in their extra-info descriptors
-   * ({@link ExtraInfoDescriptor#getReadHistory()}), not in their server
-   * descriptors.
-   *
-   * @since 1.0.0
-   */
-  public BandwidthHistory getReadHistory();
-
-  /**
-   * Return the server's history of written bytes, or null if the
-   * descriptor does not contain a bandwidth history; current Tor versions
-   * include bandwidth histories in their extra-info descriptors
-   * ({@link ExtraInfoDescriptor#getWriteHistory()}), not in their server
-   * descriptors.
-   *
-   * @since 1.0.0
-   */
-  public BandwidthHistory getWriteHistory();
-
-  /**
-   * Return true if the server uses the enhanced DNS logic, or false if
-   * doesn't use it or doesn't include an eventdns line in its
-   * descriptor; current Tor versions should be presumed to have the evdns
-   * backend.
-   *
-   * @since 1.0.0
-   */
-  public boolean getUsesEnhancedDnsLogic();
-
-  /**
-   * Return whether this server is a directory cache that provides
-   * extra-info descriptors.
-   *
-   * @since 1.0.0
-   */
-  public boolean getCachesExtraInfo();
-
-  /**
-   * Return the SHA-1 digest of the server's extra-info descriptor,
-   * encoded as 40 upper-case hexadecimal characters, or null if the
-   * server did not upload a corresponding extra-info descriptor.
-   *
-   * @since 1.0.0
-   */
-  public String getExtraInfoDigest();
-
-  /**
-   * Return the SHA-256 digest of the server's extra-info descriptor,
-   * encoded as 43 base64 characters without padding characters, or null
-   * if the server either did not upload a corresponding extra-info
-   * descriptor or did not refer to it using a SHA-256 digest.
-   *
-   * @since 1.1.0
-   */
-  public String getExtraInfoDigestSha256();
-
-  /**
-   * Return the list of hidden service descriptor version numbers that
-   * this server stores and serves, or null if it doesn't store and serve
-   * any hidden service descriptors.
-   *
-   * @since 1.0.0
-   */
-  public List<Integer> getHiddenServiceDirVersions();
-
-  /**
-   * Return the list of link protocol versions that this server
-   * supports.
-   *
-   * @since 1.0.0
-   */
-  public List<Integer> getLinkProtocolVersions();
-
-  /**
-   * Return the list of circuit protocol versions that this server
-   * supports.
-   *
-   * @since 1.0.0
-   */
-  public List<Integer> getCircuitProtocolVersions();
-
-  /**
-   * Return whether this server allows single-hop circuits to make exit
-   * connections.
-   *
-   * @since 1.0.0
-   */
-  public boolean getAllowSingleHopExits();
-
-  /**
-   * Return the default policy, {@code "accept"} or {@code "reject"}, of
-   * the IPv6 port summary, or null if the descriptor didn't contain an
-   * IPv6 exit-policy summary line which is equivalent to rejecting all
-   * streams to IPv6 targets.
-   *
-   * @since 1.0.0
-   */
-  public String getIpv6DefaultPolicy();
-
-  /**
-   * Return the port list of the IPv6 exit-policy summary, or null if the
-   * descriptor didn't contain an IPv6 exit-policy summary line which is
-   * equivalent to rejecting all streams to IPv6 targets.
-   *
-   * @since 1.0.0
-   */
-  public String getIpv6PortList();
-
-  /**
-   * Return the curve25519 public key, encoded as 43 base64 characters
-   * without padding characters, that is used for the ntor circuit
-   * extended handshake, or null if the descriptor didn't contain an
-   * ntor-onion-key line. */
-  public String getNtorOnionKey();
-
-  /**
-   * Return the Ed25519 certificate in PEM format, or null if the
-   * descriptor doesn't contain one.
-   *
-   * @since 1.1.0
-   */
-  public String getIdentityEd25519();
-
-  /**
-   * Return the Ed25519 master key, encoded as 43 base64 characters
-   * without padding characters, which was either parsed from the optional
-   * {@code "master-key-ed25519"} line or derived from the (likewise
-   * optional) Ed25519 certificate following the
-   * {@code "identity-ed25519"} line, or null if the descriptor contains
-   * neither Ed25519 master key nor Ed25519 certificate.
-   *
-   * @since 1.1.0
-   */
-  public String getMasterKeyEd25519();
-
-  /**
-   * Return the Ed25519 signature of the SHA-256 digest of the entire
-   * descriptor, encoded as 86 base64 characters without padding
-   * characters, from the first character up to and including the first
-   * space after the {@code "router-sig-ed25519"} string, prefixed with
-   * the string {@code "Tor router descriptor signature v1"}.
-   *
-   * @since 1.1.0
-   */
-  public String getRouterSignatureEd25519();
-
-  /**
-   * Return an RSA-1024 signature in PEM format, generated using the
-   * server's onion key, that proves that the party creating the
-   * descriptor had control over the private key corresponding to the
-   * onion key, or null if the descriptor does not contain such a
-   * signature.
-   *
-   * @since 1.1.0
-   */
-  public String getOnionKeyCrosscert();
-
-  /**
-   * Return an Ed25519 signature in PEM format, generated using the
-   * server's ntor onion key, that proves that the party creating the
-   * descriptor had control over the private key corresponding to the ntor
-   * onion key, or null if the descriptor does not contain such a
-   * signature.
-   *
-   * @since 1.1.0
-   */
-  public String getNtorOnionKeyCrosscert();
-
-  /**
-   * Return the sign of the Ed25519 public key corresponding to the ntor
-   * onion key as 0 or 1, or -1 if the descriptor does not contain this
-   * information.
-   *
-   * @since 1.1.0
-   */
-  public int getNtorOnionKeyCrosscertSign();
-
-  /**
-   * Return whether the server accepts "tunneled" directory requests using
-   * a BEGIN_DIR cell over the server's OR port.
-   *
-   * @since 1.3.0
-   */
-  public boolean getTunnelledDirServer();
-}
-
diff --git a/src/org/torproject/descriptor/TorperfResult.java b/src/org/torproject/descriptor/TorperfResult.java
deleted file mode 100644
index 188200b..0000000
--- a/src/org/torproject/descriptor/TorperfResult.java
+++ /dev/null
@@ -1,215 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-
-package org.torproject.descriptor;
-
-import java.util.List;
-import java.util.SortedMap;
-
-/**
- * Contains performance measurement results from making simple HTTP
- * requests over the Tor network.
- *
- * <p>The performance measurement service Torperf publishes performance
- * data from making simple HTTP requests over the Tor network.  Torperf
- * uses a trivial SOCKS client to download files of various sizes over the
- * Tor network and notes how long substeps take.</p>
- *
- * @since 1.0.0
- */
-public interface TorperfResult extends Descriptor {
-
-  /**
-   * Return all unrecognized keys together with their values, or null if
-   * all keys were recognized.
-   *
-   * @since 1.2.0
-   */
-  public SortedMap<String, String> getUnrecognizedKeys();
-
-  /**
-   * Return the configured name of the data source.
-   *
-   * @since 1.0.0
-   */
-  public String getSource();
-
-  /**
-   * Return the configured file size in bytes.
-   *
-   * @since 1.0.0
-   */
-  public int getFileSize();
-
-  /**
-   * Return the time in milliseconds since the epoch when the connection
-   * process started.
-   *
-   * @since 1.0.0
-   */
-  public long getStartMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the socket was
-   * created.
-   *
-   * @since 1.0.0
-   */
-  public long getSocketMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the socket was
-   * connected.
-   *
-   * @since 1.0.0
-   */
-  public long getConnectMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when SOCKS 5
-   * authentication methods have been negotiated.
-   *
-   * @since 1.0.0
-   */
-  public long getNegotiateMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the SOCKS
-   * request was sent.
-   *
-   * @since 1.0.0
-   */
-  public long getRequestMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the SOCKS
-   * response was received.
-   *
-   * @since 1.0.0
-   */
-  public long getResponseMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the HTTP
-   * request was written.
-   *
-   * @since 1.0.0
-   */
-  public long getDataRequestMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the first
-   * response was received.
-   *
-   * @since 1.0.0
-   */
-  public long getDataResponseMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the payload was
-   * complete.
-   *
-   * @since 1.0.0
-   */
-  public long getDataCompleteMillis();
-
-  /**
-   * Return the total number of bytes written.
-   *
-   * @since 1.0.0
-   */
-  public int getWriteBytes();
-
-  /**
-   * Return the total number of bytes read.
-   *
-   * @since 1.0.0
-   */
-  public int getReadBytes();
-
-  /**
-   * Return whether the request timed out (as opposed to failing), or
-   * null if the torperf line didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public Boolean didTimeout();
-
-  /**
-   * Return the times in milliseconds since the epoch when {@code x%} of
-   * expected bytes were read for {@code 0 <= x <= 100}, or null if the
-   * torperf line didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public SortedMap<Integer, Long> getDataPercentiles();
-
-  /**
-   * Return the time in milliseconds since the epoch when the circuit was
-   * launched, or -1 if the torperf line didn't contain that
-   * information.
-   *
-   * @since 1.0.0
-   */
-  public long getLaunchMillis();
-
-  /**
-   * Return the time in milliseconds since the epoch when the circuit was
-   * used, or -1 if the torperf line didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public long getUsedAtMillis();
-
-  /**
-   * Return a list of fingerprints of the relays in the circuit, or null
-   * if the torperf line didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public List<String> getPath();
-
-  /**
-   * Return a list of times in milliseconds since the epoch when circuit
-   * hops were built, or null if the torperf line didn't contain that
-   * information.
-   *
-   * @since 1.0.0
-   */
-  public List<Long> getBuildTimes();
-
-  /**
-   * Return the circuit build timeout that the Tor client used when
-   * building this circuit, or -1 if the torperf line didn't contain that
-   * information.
-   *
-   * @since 1.0.0
-   */
-  public long getTimeout();
-
-  /**
-   * Return the circuit build time quantile that the Tor client uses to
-   * determine its circuit-build timeout, or -1 if the torperf line
-   * didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public double getQuantile();
-
-  /**
-   * Return the identifier of the circuit used for this measurement, or
-   * -1 if the torperf line didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public int getCircId();
-
-  /**
-   * Return the identifier of the stream used for this measurement, or -1
-   * if the torperf line didn't contain that information.
-   *
-   * @since 1.0.0
-   */
-  public int getUsedBy();
-}
-
diff --git a/src/org/torproject/descriptor/impl/BandwidthHistoryImpl.java b/src/org/torproject/descriptor/impl/BandwidthHistoryImpl.java
deleted file mode 100644
index 295e0a4..0000000
--- a/src/org/torproject/descriptor/impl/BandwidthHistoryImpl.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import org.torproject.descriptor.BandwidthHistory;
-
-public class BandwidthHistoryImpl implements BandwidthHistory {
-
-  protected BandwidthHistoryImpl(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    boolean isValid = false;
-    this.line = line;
-    if (partsNoOpt.length == 5 || partsNoOpt.length == 6) {
-      try {
-        this.historyEndMillis = ParseHelper.parseTimestampAtIndex(line,
-            partsNoOpt, 1, 2);
-        if (partsNoOpt[3].startsWith("(") &&
-            partsNoOpt[4].startsWith("s)")) {
-          this.intervalLength = Long.parseLong(partsNoOpt[3].
-              substring(1));
-          if (this.intervalLength <= 0L) {
-            throw new DescriptorParseException("Only positive interval "
-                + "lengths are allowed in line '" + line + "'.");
-          }
-          String[] values = null;
-          if (partsNoOpt.length == 5 &&
-              partsNoOpt[4].equals("s)")) {
-            /* There are no bandwidth values to parse. */
-            isValid = true;
-          } else if (partsNoOpt.length == 6) {
-            /* There are bandwidth values to parse. */
-            values = partsNoOpt[5].split(",", -1);
-          } else if (partsNoOpt[4].length() > 2) {
-            /* There are bandwidth values to parse, but there is no space
-             * between "s)" and "0,0,0,0".  Very old Tor versions around
-             * Tor 0.0.8 wrote such history lines, and even though
-             * dir-spec.txt implies a space here, the old format isn't
-             * totally broken.  Let's pretend there's a space. */
-            values = partsNoOpt[4].substring(2).split(",", -1);
-          }
-          if (values != null) {
-            this.bandwidthValues = new long[values.length];
-            for (int i = values.length - 1; i >= 0; i--) {
-              long bandwidthValue = Long.parseLong(values[i]);
-              if (bandwidthValue < 0L) {
-                throw new DescriptorParseException("Negative bandwidth "
-                    + "values are not allowed in line '" + line + "'.");
-              }
-              this.bandwidthValues[i] = bandwidthValue;
-            }
-            isValid = true;
-          }
-        }
-      } catch (NumberFormatException e) {
-        /* Handle below. */
-      }
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("Invalid bandwidth-history line "
-          + "'" + line + "'.");
-    }
-  }
-
-  private String line;
-  @Override
-  public String getLine() {
-    return this.line;
-  }
-
-  private long historyEndMillis;
-  @Override
-  public long getHistoryEndMillis() {
-    return this.historyEndMillis;
-  }
-
-  private long intervalLength;
-  @Override
-  public long getIntervalLength() {
-    return this.intervalLength;
-  }
-
-  private long[] bandwidthValues;
-  @Override
-  public SortedMap<Long, Long> getBandwidthValues() {
-    SortedMap<Long, Long> result = new TreeMap<>();
-    if (this.bandwidthValues != null) {
-      long endMillis = this.historyEndMillis;
-      for (int i = this.bandwidthValues.length - 1; i >= 0; i--) {
-        result.put(endMillis, bandwidthValues[i]);
-        endMillis -= this.intervalLength * 1000L;
-      }
-    }
-    return result;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/BlockingIteratorImpl.java b/src/org/torproject/descriptor/impl/BlockingIteratorImpl.java
deleted file mode 100644
index 66426d8..0000000
--- a/src/org/torproject/descriptor/impl/BlockingIteratorImpl.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.NoSuchElementException;
-import java.util.Queue;
-
-/* Provide an iterator for a queue of objects and block when there are
- * currently no objects in the queue.  Allow the producer to signal that
- * there won't be further objects and unblock any waiting consumers. */
-public class BlockingIteratorImpl<T> implements Iterator<T> {
-
-  /* Queue containing produced elemnts waiting for consumers. */
-  private Queue<T> queue = new LinkedList<>();
-
-  /* Maximum number of elements in queue. */
-  private int maxQueueSize = 100;
-
-  /* Restrict object construction to the impl package. */
-  protected BlockingIteratorImpl() {
-  }
-
-  /* Create instance with maximum queue size. */
-  protected BlockingIteratorImpl(int maxQueueSize) {
-    this.maxQueueSize = maxQueueSize;
-  }
-
-  /* Add an object to the queue if there's still room. */
-  protected synchronized void add(T object) {
-    if (this.outOfDescriptors) {
-      throw new IllegalStateException("Internal error: Adding results to "
-          + "descriptor queue not allowed after sending end-of-stream "
-          + "object.");
-    }
-    while (this.queue.size() >= this.maxQueueSize) {
-      try {
-        wait();
-      } catch (InterruptedException e) {
-      }
-    }
-    this.queue.offer(object);
-    notifyAll();
-  }
-
-  /* Signalize that there won't be any further objects to be enqueued. */
-  private boolean outOfDescriptors = false;
-  protected synchronized void setOutOfDescriptors() {
-    if (this.outOfDescriptors) {
-      throw new IllegalStateException("Internal error: Sending "
-          + "end-of-stream object only permitted once.");
-    }
-    this.outOfDescriptors = true;
-    notifyAll();
-  }
-
-  /* Return whether there are more objects.  Block if there are currently
-   * no objects, but the producer hasn't signalized that there won't be
-   * further objects. */
-  @Override
-  public synchronized boolean hasNext() {
-    while (!this.outOfDescriptors && this.queue.isEmpty()) {
-      try {
-        wait();
-      } catch (InterruptedException e) {
-      }
-    }
-    return this.queue.peek() != null;
-  }
-
-  /* Return the next object in the queue or throw an exception when there
-   * are no further objects.  Block if there are currently no objects, but
-   * the producer hasn't signalized that there won't be further
-   * objects. */
-  @Override
-  public synchronized T next() {
-    while (!this.outOfDescriptors && this.queue.isEmpty()) {
-      try {
-        wait();
-      } catch (InterruptedException e) {
-      }
-    }
-    if (this.queue.peek() == null) {
-      throw new NoSuchElementException();
-    }
-    notifyAll();
-    return this.queue.remove();
-  }
-
-  /* Don't support explicitly removing objects.  They are removed
-   * anyway. */
-  @Override
-  public void remove() {
-    throw new UnsupportedOperationException();
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/BridgeExtraInfoDescriptorImpl.java b/src/org/torproject/descriptor/impl/BridgeExtraInfoDescriptorImpl.java
deleted file mode 100644
index 15d40d8..0000000
--- a/src/org/torproject/descriptor/impl/BridgeExtraInfoDescriptorImpl.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.torproject.descriptor.BridgeExtraInfoDescriptor;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-
-public class BridgeExtraInfoDescriptorImpl
-    extends ExtraInfoDescriptorImpl implements BridgeExtraInfoDescriptor {
-
-  protected static List<ExtraInfoDescriptor> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<ExtraInfoDescriptor> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
-        "extra-info ");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      ExtraInfoDescriptor parsedDescriptor =
-          new BridgeExtraInfoDescriptorImpl(descriptorBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected BridgeExtraInfoDescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/BridgeNetworkStatusImpl.java b/src/org/torproject/descriptor/impl/BridgeNetworkStatusImpl.java
deleted file mode 100644
index bf3804d..0000000
--- a/src/org/torproject/descriptor/impl/BridgeNetworkStatusImpl.java
+++ /dev/null
@@ -1,230 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.SortedMap;
-import java.util.TimeZone;
-
-import org.torproject.descriptor.BridgeNetworkStatus;
-
-/* Contains a bridge network status. */
-public class BridgeNetworkStatusImpl extends NetworkStatusImpl
-    implements BridgeNetworkStatus {
-
-  protected BridgeNetworkStatusImpl(byte[] statusBytes,
-      String fileName, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(statusBytes, failUnrecognizedDescriptorLines, false, false);
-    this.setPublishedMillisFromFileName(fileName);
-  }
-
-  private void setPublishedMillisFromFileName(String fileName)
-      throws DescriptorParseException {
-    if (this.publishedMillis != 0L) {
-      /* We already learned the publication timestamp from parsing the
-       * "published" line. */
-      return;
-    }
-    if (fileName.length() ==
-        "20000101-000000-4A0CCD2DDC7995083D73F5D667100C8A5831F16D".
-        length()) {
-      String publishedString = fileName.substring(0,
-          "yyyyMMdd-HHmmss".length());
-      try {
-        SimpleDateFormat fileNameFormat = new SimpleDateFormat(
-            "yyyyMMdd-HHmmss");
-        fileNameFormat.setLenient(false);
-        fileNameFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-        this.publishedMillis = fileNameFormat.parse(publishedString).
-            getTime();
-      } catch (ParseException e) {
-      }
-    }
-    if (this.publishedMillis == 0L) {
-      throw new DescriptorParseException("Unrecognized bridge network "
-          + "status file name '" + fileName + "'.");
-    }
-  }
-
-  protected void parseHeader(byte[] headerBytes)
-      throws DescriptorParseException {
-    /* Initialize flag-thresholds values here for the case that the status
-     * doesn't contain those values.  Initializing them in the constructor
-     * or when declaring variables wouldn't work, because those parts are
-     * evaluated later and would overwrite everything we parse here. */
-    this.stableUptime = -1L;
-    this.stableMtbf = -1L;
-    this.fastBandwidth = -1L;
-    this.guardWfu = -1.0;
-    this.guardTk = -1L;
-    this.guardBandwidthIncludingExits = -1L;
-    this.guardBandwidthExcludingExits = -1L;
-    this.enoughMtbfInfo = -1;
-    this.ignoringAdvertisedBws = -1;
-
-    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "published":
-        this.parsePublishedLine(line, parts);
-        break;
-      case "flag-thresholds":
-        this.parseFlagThresholdsLine(line, parts);
-        break;
-      default:
-        if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in bridge network status.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private void parsePublishedLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseFlagThresholdsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 2) {
-      throw new DescriptorParseException("No flag thresholds in line '"
-          + line + "'.");
-    }
-    SortedMap<String, String> flagThresholds =
-        ParseHelper.parseKeyValueStringPairs(line, parts, 1, "=");
-    try {
-      for (Map.Entry<String, String> e : flagThresholds.entrySet()) {
-        switch (e.getKey()) {
-        case "stable-uptime":
-          this.stableUptime = Long.parseLong(e.getValue());
-          break;
-        case "stable-mtbf":
-          this.stableMtbf = Long.parseLong(e.getValue());
-          break;
-        case "fast-speed":
-          this.fastBandwidth = Long.parseLong(e.getValue());
-          break;
-        case "guard-wfu":
-          this.guardWfu = Double.parseDouble(e.getValue().
-              replaceAll("%", ""));
-          break;
-        case "guard-tk":
-          this.guardTk = Long.parseLong(e.getValue());
-          break;
-        case "guard-bw-inc-exits":
-          this.guardBandwidthIncludingExits =
-              Long.parseLong(e.getValue());
-          break;
-        case "guard-bw-exc-exits":
-          this.guardBandwidthExcludingExits =
-              Long.parseLong(e.getValue());
-          break;
-        case "enough-mtbf":
-          this.enoughMtbfInfo = Integer.parseInt(e.getValue());
-          break;
-        case "ignoring-advertised-bws":
-          this.ignoringAdvertisedBws = Integer.parseInt(e.getValue());
-          break;
-        }
-      }
-    } catch (NumberFormatException ex) {
-      throw new DescriptorParseException("Illegal value in line '"
-          + line + "'.");
-    }
-  }
-
-  protected void parseDirSource(byte[] dirSourceBytes)
-      throws DescriptorParseException {
-    throw new DescriptorParseException("No directory source expected in "
-        + "bridge network status.");
-  }
-
-  protected void parseFooter(byte[] footerBytes)
-      throws DescriptorParseException {
-    throw new DescriptorParseException("No directory footer expected in "
-        + "bridge network status.");
-  }
-
-  protected void parseDirectorySignature(byte[] directorySignatureBytes)
-      throws DescriptorParseException {
-    throw new DescriptorParseException("No directory signature expected "
-        + "in bridge network status.");
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private long stableUptime;
-  @Override
-  public long getStableUptime() {
-    return this.stableUptime;
-  }
-
-  private long stableMtbf;
-  @Override
-  public long getStableMtbf() {
-    return this.stableMtbf;
-  }
-
-  private long fastBandwidth;
-  @Override
-  public long getFastBandwidth() {
-    return this.fastBandwidth;
-  }
-
-  private double guardWfu;
-  @Override
-  public double getGuardWfu() {
-    return this.guardWfu;
-  }
-
-  private long guardTk;
-  @Override
-  public long getGuardTk() {
-    return this.guardTk;
-  }
-
-  private long guardBandwidthIncludingExits;
-  @Override
-  public long getGuardBandwidthIncludingExits() {
-    return this.guardBandwidthIncludingExits;
-  }
-
-  private long guardBandwidthExcludingExits;
-  @Override
-  public long getGuardBandwidthExcludingExits() {
-    return this.guardBandwidthExcludingExits;
-  }
-
-  private int enoughMtbfInfo;
-  @Override
-  public int getEnoughMtbfInfo() {
-    return this.enoughMtbfInfo;
-  }
-
-  private int ignoringAdvertisedBws;
-  @Override
-  public int getIgnoringAdvertisedBws() {
-    return this.ignoringAdvertisedBws;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/BridgePoolAssignmentImpl.java b/src/org/torproject/descriptor/impl/BridgePoolAssignmentImpl.java
deleted file mode 100644
index 99578e8..0000000
--- a/src/org/torproject/descriptor/impl/BridgePoolAssignmentImpl.java
+++ /dev/null
@@ -1,99 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import org.torproject.descriptor.BridgePoolAssignment;
-
-/* TODO Write a test class. */
-public class BridgePoolAssignmentImpl extends DescriptorImpl
-    implements BridgePoolAssignment {
-
-  protected static List<BridgePoolAssignment> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<BridgePoolAssignment> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
-        "bridge-pool-assignment ");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      BridgePoolAssignment parsedDescriptor =
-          new BridgePoolAssignmentImpl(descriptorBytes,
-              failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected BridgePoolAssignmentImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.parseDescriptorBytes();
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList(
-        new String[] { "bridge-pool-assignment" }));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    this.checkFirstKeyword("bridge-pool-assignment");
-    this.clearParsedKeywords();
-    return;
-  }
-
-  private void parseDescriptorBytes() throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
-        useDelimiter("\n");
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.startsWith("bridge-pool-assignment ")) {
-        this.parseBridgePoolAssignmentLine(line);
-      } else {
-        this.parseBridgeLine(line);
-      }
-    }
-  }
-
-  private void parseBridgePoolAssignmentLine(String line)
-      throws DescriptorParseException {
-    String[] parts = line.split("[ \t]+");
-    if (parts.length != 3) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in bridge pool assignment.");
-    }
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
-        parts, 1, 2);
-  }
-
-  private void parseBridgeLine(String line)
-      throws DescriptorParseException {
-    String[] parts = line.split("[ \t]+");
-    if (parts.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in bridge pool assignment.");
-    }
-    String fingerprint = ParseHelper.parseTwentyByteHexString(line,
-        parts[0]);
-    String poolAndDetails = line.substring(line.indexOf(" ") + 1);
-    this.entries.put(fingerprint, poolAndDetails);
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private SortedMap<String, String> entries = new TreeMap<>();
-  @Override
-  public SortedMap<String, String> getEntries() {
-    return new TreeMap<>(this.entries);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/BridgeServerDescriptorImpl.java b/src/org/torproject/descriptor/impl/BridgeServerDescriptorImpl.java
deleted file mode 100644
index eb2b933..0000000
--- a/src/org/torproject/descriptor/impl/BridgeServerDescriptorImpl.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.torproject.descriptor.BridgeServerDescriptor;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ServerDescriptor;
-
-public class BridgeServerDescriptorImpl extends ServerDescriptorImpl
-    implements BridgeServerDescriptor {
-
-  protected static List<ServerDescriptor> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<ServerDescriptor> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
-        "router ");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      ServerDescriptor parsedDescriptor =
-          new BridgeServerDescriptorImpl(descriptorBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected BridgeServerDescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorCollectorImpl.java b/src/org/torproject/descriptor/impl/DescriptorCollectorImpl.java
deleted file mode 100644
index 1a030ef..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorCollectorImpl.java
+++ /dev/null
@@ -1,249 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.BufferedOutputStream;
-import java.io.BufferedInputStream;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.util.Arrays;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.Stack;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.zip.GZIPInputStream;
-
-import org.torproject.descriptor.DescriptorCollector;
-
-public class DescriptorCollectorImpl implements DescriptorCollector {
-
-  @Override
-  public void collectDescriptors(String collecTorBaseUrl,
-      String[] remoteDirectories, long minLastModified,
-      File localDirectory, boolean deleteExtraneousLocalFiles) {
-    collecTorBaseUrl = collecTorBaseUrl.endsWith("/")
-        ? collecTorBaseUrl.substring(0, collecTorBaseUrl.length() - 1)
-        : collecTorBaseUrl;
-    if (minLastModified < 0) {
-      throw new IllegalArgumentException("A negative minimum "
-          + "last-modified time is not permitted.");
-    }
-    if (localDirectory.exists() && !localDirectory.isDirectory()) {
-      throw new IllegalArgumentException("Local directory already exists "
-          + "and is not a directory.");
-    }
-    SortedMap<String, Long> localFiles =
-        this.statLocalDirectory(localDirectory);
-    SortedMap<String, String> fetchedDirectoryListings =
-        this.fetchRemoteDirectories(collecTorBaseUrl, remoteDirectories);
-    SortedSet<String> parsedDirectories = new TreeSet<>();
-    SortedMap<String, Long> remoteFiles = new TreeMap<>();
-    for (Map.Entry<String, String> e :
-        fetchedDirectoryListings.entrySet()) {
-      String remoteDirectory = e.getKey();
-      String directoryListing = e.getValue();
-      SortedMap<String, Long> parsedRemoteFiles =
-          this.parseDirectoryListing(remoteDirectory, directoryListing);
-      if (parsedRemoteFiles == null) {
-        continue;
-      }
-      parsedDirectories.add(remoteDirectory);
-      remoteFiles.putAll(parsedRemoteFiles);
-    }
-    this.fetchRemoteFiles(collecTorBaseUrl, remoteFiles, minLastModified,
-        localDirectory, localFiles);
-    if (deleteExtraneousLocalFiles) {
-      this.deleteExtraneousLocalFiles(parsedDirectories, remoteFiles,
-          localDirectory, localFiles);
-    }
-  }
-
-  SortedMap<String, Long> statLocalDirectory(
-      File localDirectory) {
-    SortedMap<String, Long> localFiles = new TreeMap<>();
-    if (!localDirectory.exists()) {
-      return localFiles;
-    }
-    Stack<File> files = new Stack<>();
-    files.add(localDirectory);
-    while (!files.isEmpty()) {
-      File file = files.pop();
-      if (file.isDirectory()) {
-        files.addAll(Arrays.asList(file.listFiles()));
-      } else {
-        String localPath = file.getPath().substring(
-            localDirectory.getPath().length());
-        localFiles.put(localPath, file.lastModified());
-      }
-    }
-    return localFiles;
-  }
-
-  SortedMap<String, String> fetchRemoteDirectories(
-      String collecTorBaseUrl, String[] remoteDirectories) {
-    SortedMap<String, String> fetchedDirectoryListings = new TreeMap<>();
-    for (String remoteDirectory : remoteDirectories) {
-      String remoteDirectoryWithSlashAtBeginAndEnd =
-          (remoteDirectory.startsWith("/") ? "" : "/") + remoteDirectory
-          + (remoteDirectory.endsWith("/") ? "" : "/");
-      String directoryUrl = collecTorBaseUrl
-          + remoteDirectoryWithSlashAtBeginAndEnd;
-      String directoryListing = this.fetchRemoteDirectory(directoryUrl);
-      if (directoryListing.length() > 0) {
-        fetchedDirectoryListings.put(
-            remoteDirectoryWithSlashAtBeginAndEnd, directoryListing);
-      }
-    }
-    return fetchedDirectoryListings;
-  }
-
-  String fetchRemoteDirectory(String url) {
-    StringBuilder sb = new StringBuilder();
-    HttpURLConnection huc = null;
-    try {
-      URL u = new URL(url);
-      huc = (HttpURLConnection) u.openConnection();
-      huc.setRequestMethod("GET");
-      huc.connect();
-      int responseCode = huc.getResponseCode();
-      if (responseCode == 200) {
-        BufferedReader br = new BufferedReader(new InputStreamReader(
-            huc.getInputStream()));
-        String line;
-        while ((line = br.readLine()) != null) {
-          sb.append(line).append("\n");
-        }
-        br.close();
-      }
-    } catch (IOException e) {
-      e.printStackTrace();
-      if (huc != null) {
-        huc.disconnect();
-      }
-      return "";
-    }
-    return sb.toString();
-  }
-
-  final Pattern DIRECTORY_LISTING_LINE_PATTERN =
-      Pattern.compile(".* href=\"([^\"/]+)\"" /* filename */
-      + ".*>(\\d{2}-\\w{3}-\\d{4} \\d{2}:\\d{2})\\s*<.*"); /* dateTime */
-
-  SortedMap<String, Long> parseDirectoryListing(
-      String remoteDirectory, String directoryListing) {
-    SortedMap<String, Long> remoteFiles = new TreeMap<>();
-    DateFormat dateTimeFormat = ParseHelper.getDateFormat(
-        "dd-MMM-yyyy HH:mm");
-    try {
-      Scanner s = new Scanner(directoryListing);
-      s.useDelimiter("\n");
-      while (s.hasNext()) {
-        String line = s.next();
-        Matcher matcher = DIRECTORY_LISTING_LINE_PATTERN.matcher(line);
-        if (matcher.matches()) {
-          String filename = matcher.group(1);
-          long lastModifiedMillis = dateTimeFormat.parse(
-              matcher.group(2)).getTime();
-          remoteFiles.put(remoteDirectory + filename, lastModifiedMillis);
-        }
-      }
-      s.close();
-    } catch (ParseException e) {
-      e.printStackTrace();
-      return null;
-    }
-    return remoteFiles;
-  }
-
-  void fetchRemoteFiles(String collecTorBaseUrl,
-      SortedMap<String, Long> remoteFiles, long minLastModified,
-      File localDirectory, SortedMap<String, Long> localFiles) {
-    for (Map.Entry<String, Long> e : remoteFiles.entrySet()) {
-      String filename = e.getKey();
-      long lastModifiedMillis = e.getValue();
-      if (lastModifiedMillis < minLastModified ||
-          (localFiles.containsKey(filename) &&
-          localFiles.get(filename) >= lastModifiedMillis)) {
-        continue;
-      }
-      String url = collecTorBaseUrl + filename;
-      File destinationFile = new File(localDirectory.getPath()
-          + filename);
-      this.fetchRemoteFile(url, destinationFile, lastModifiedMillis);
-    }
-  }
-
-  void fetchRemoteFile(String url, File destinationFile,
-      long lastModifiedMillis) {
-    HttpURLConnection huc = null;
-    try {
-      File destinationDirectory = destinationFile.getParentFile();
-      destinationDirectory.mkdirs();
-      File tempDestinationFile = new File(destinationDirectory, "."
-          + destinationFile.getName());
-      BufferedOutputStream bos = new BufferedOutputStream(
-          new FileOutputStream(tempDestinationFile));
-      URL u = new URL(url);
-      huc = (HttpURLConnection) u.openConnection();
-      huc.setRequestMethod("GET");
-      if (!url.endsWith(".xz")) {
-        huc.addRequestProperty("Accept-Encoding", "gzip");
-      }
-      huc.connect();
-      int responseCode = huc.getResponseCode();
-      if (responseCode == 200) {
-        InputStream is;
-        if (huc.getContentEncoding() != null &&
-            huc.getContentEncoding().equalsIgnoreCase("gzip")) {
-          is = new GZIPInputStream(huc.getInputStream());
-        } else {
-          is = huc.getInputStream();
-        }
-        BufferedInputStream bis = new BufferedInputStream(is);
-        int len;
-        byte[] data = new byte[8192];
-        while ((len = bis.read(data, 0, 8192)) >= 0) {
-          bos.write(data, 0, len);
-        }
-        bis.close();
-        bos.close();
-        tempDestinationFile.renameTo(destinationFile);
-        destinationFile.setLastModified(lastModifiedMillis);
-      }
-    } catch (IOException e) {
-      e.printStackTrace();
-      if (huc != null) {
-        huc.disconnect();
-      }
-    }
-  }
-
-  void deleteExtraneousLocalFiles(
-      SortedSet<String> parsedDirectories,
-      SortedMap<String, Long> remoteFiles, File localDirectory,
-      SortedMap<String, Long> localFiles) {
-    for (String localPath : localFiles.keySet()) {
-      for (String remoteDirectory : parsedDirectories) {
-        if (localPath.startsWith(remoteDirectory)) {
-          if (!remoteFiles.containsKey(localPath)) {
-            new File(localDirectory.getPath() + localPath).delete();
-          }
-        }
-      }
-    }
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorDownloaderImpl.java b/src/org/torproject/descriptor/impl/DescriptorDownloaderImpl.java
deleted file mode 100644
index e726ce9..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorDownloaderImpl.java
+++ /dev/null
@@ -1,283 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import org.torproject.descriptor.DescriptorRequest;
-import org.torproject.descriptor.DescriptorDownloader;
-
-public class DescriptorDownloaderImpl
-    implements DescriptorDownloader {
-
-  private boolean hasStartedDownloading = false;
-
-  private SortedMap<String, DirectoryDownloader> directoryAuthorities =
-      new TreeMap<>();
-  @Override
-  public void addDirectoryAuthority(String nickname, String ip,
-      int dirPort) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.checkDirectoryParameters(nickname, ip, dirPort);
-    DirectoryDownloader directoryAuthority = new DirectoryDownloader(
-        nickname, ip, dirPort);
-    this.directoryAuthorities.put(nickname, directoryAuthority);
-  }
-
-  private SortedMap<String, DirectoryDownloader> directoryMirrors =
-      new TreeMap<>();
-  @Override
-  public void addDirectoryMirror(String nickname, String ip,
-      int dirPort) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.checkDirectoryParameters(nickname, ip, dirPort);
-    DirectoryDownloader directoryMirror = new DirectoryDownloader(
-        nickname, ip, dirPort);
-    this.directoryMirrors.put(nickname, directoryMirror);
-    /* TODO Implement prioritizing mirrors for non-vote downloads. */
-    throw new UnsupportedOperationException("Prioritizing directory "
-        + "mirrors over directory authorities is not implemented yet.  "
-        + "Until it is, configuring directory mirrors is misleading and "
-        + "therefore not supported.");
-  }
-
-  private void checkDirectoryParameters(String nickname, String ip,
-      int dirPort) {
-    if (nickname == null || nickname.length() < 1) {
-      throw new IllegalArgumentException("'" + nickname + "' is not a "
-          + "valid nickname.");
-    }
-    if (ip == null || ip.length() < 7 || ip.split("\\.").length != 4) {
-      throw new IllegalArgumentException("'" + ip + "' is not a valid IP "
-          + "address.");
-    }
-    if (dirPort < 1 || dirPort > 65535) {
-      throw new IllegalArgumentException(String.valueOf(dirPort) + " is "
-          + "not a valid DirPort.");
-    }
-    /* TODO Relax the requirement for directory nicknames to be unique.
-     * In theory, we can identify them by ip+port. */
-    if (this.directoryAuthorities.containsKey(nickname) ||
-        this.directoryMirrors.containsKey(nickname)) {
-      throw new IllegalArgumentException("Directory nicknames must be "
-          + "unique.");
-    }
-  }
-
-  private boolean downloadConsensus = false;
-  @Override
-  public void setIncludeCurrentConsensus() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.downloadConsensus = true;
-  }
-
-  private boolean downloadConsensusFromAllAuthorities = false;
-  @Override
-  public void setIncludeCurrentConsensusFromAllDirectoryAuthorities() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.downloadConsensusFromAllAuthorities = true;
-  }
-
-  private boolean includeCurrentReferencedVotes = false;
-  @Override
-  public void setIncludeCurrentReferencedVotes() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.includeCurrentReferencedVotes = true;
-  }
-
-  private Set<String> downloadVotes = new HashSet<>();
-  @Override
-  public void setIncludeCurrentVote(String fingerprint) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.checkVoteFingerprint(fingerprint);
-    this.downloadVotes.add(fingerprint);
-  }
-
-  @Override
-  public void setIncludeCurrentVotes(Set<String> fingerprints) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    if (fingerprints == null) {
-      throw new IllegalArgumentException("Set of fingerprints must not "
-          + "be null.");
-    }
-    for (String fingerprint : fingerprints) {
-      this.checkVoteFingerprint(fingerprint);
-    }
-    for (String fingerprint : fingerprints) {
-      this.setIncludeCurrentVote(fingerprint);
-    }
-  }
-
-  private void checkVoteFingerprint(String fingerprint) {
-    if (fingerprint == null || fingerprint.length() != 40) {
-      throw new IllegalArgumentException("'" + fingerprint + "' is not a "
-          + "valid fingerprint.");
-    }
-  }
-
-  @Override
-  public void setIncludeReferencedServerDescriptors() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    /* TODO Implement me. */
-    throw new UnsupportedOperationException("Downloading server "
-        + "descriptors is not implemented yet.");
-  }
-
-  @Override
-  public void setExcludeServerDescriptor(String identifier) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    /* TODO Implement me. */
-    throw new UnsupportedOperationException("Downloading server "
-        + "descriptors is not implemented yet.");
-  }
-
-  @Override
-  public void setExcludeServerDescriptors(Set<String> identifier) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    /* TODO Implement me. */
-    throw new UnsupportedOperationException("Downloading server "
-        + "descriptors is not implemented yet.");
-  }
-
-  @Override
-  public void setIncludeReferencedExtraInfoDescriptors() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    /* TODO Implement me. */
-    throw new UnsupportedOperationException("Downloading extra-info "
-        + "descriptors is not implemented yet.");
-  }
-
-  @Override
-  public void setExcludeExtraInfoDescriptor(String identifier) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    /* TODO Implement me. */
-    throw new UnsupportedOperationException("Downloading extra-info "
-        + "descriptors is not implemented yet.");
-  }
-
-  @Override
-  public void setExcludeExtraInfoDescriptors(Set<String> identifiers) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    /* TODO Implement me. */
-    throw new UnsupportedOperationException("Downloading extra-info "
-        + "descriptors is not implemented yet.");
-  }
-
-  private long readTimeoutMillis = 60L * 1000L;
-  @Override
-  public void setReadTimeout(long readTimeoutMillis) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    if (readTimeoutMillis < 0L) {
-      throw new IllegalArgumentException("Read timeout value "
-          + String.valueOf(readTimeoutMillis) + " may not be "
-          + "negative.");
-    }
-    this.readTimeoutMillis = readTimeoutMillis;
-  }
-
-  private long connectTimeoutMillis = 60L * 1000L;
-  @Override
-  public void setConnectTimeout(long connectTimeoutMillis) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    if (connectTimeoutMillis < 0L) {
-      throw new IllegalArgumentException("Connect timeout value "
-          + String.valueOf(connectTimeoutMillis) + " may not be "
-          + "negative.");
-    }
-    this.connectTimeoutMillis = connectTimeoutMillis;
-  }
-
-  private long globalTimeoutMillis = 60L * 60L * 1000L;
-  @Override
-  public void setGlobalTimeout(long globalTimeoutMillis) {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    if (globalTimeoutMillis < 0L) {
-      throw new IllegalArgumentException("Global timeout value "
-          + String.valueOf(globalTimeoutMillis) + " may not be "
-          + "negative.");
-    }
-    this.globalTimeoutMillis = globalTimeoutMillis;
-  }
-
-  private boolean failUnrecognizedDescriptorLines = false;
-  @Override
-  public void setFailUnrecognizedDescriptorLines() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to download.");
-    }
-    this.failUnrecognizedDescriptorLines = true;
-  }
-
-  @Override
-  public Iterator<DescriptorRequest> downloadDescriptors() {
-    if (this.hasStartedDownloading) {
-      throw new IllegalStateException("Initiating downloads is only "
-          + "permitted once.");
-    }
-    this.hasStartedDownloading = true;
-    DownloadCoordinatorImpl downloadCoordinator =
-        new DownloadCoordinatorImpl(this.directoryAuthorities,
-        this.directoryMirrors, this.downloadConsensus,
-        this.downloadConsensusFromAllAuthorities, this.downloadVotes,
-        this.includeCurrentReferencedVotes, this.connectTimeoutMillis,
-        this.readTimeoutMillis, this.globalTimeoutMillis,
-        this.failUnrecognizedDescriptorLines);
-    Iterator<DescriptorRequest> descriptorQueue = downloadCoordinator.
-        getDescriptorQueue();
-    return descriptorQueue;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorFileImpl.java b/src/org/torproject/descriptor/impl/DescriptorFileImpl.java
deleted file mode 100644
index 801c546..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorFileImpl.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.List;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorFile;
-
-public class DescriptorFileImpl implements DescriptorFile {
-
-  private File directory;
-  protected void setDirectory(File directory) {
-    this.directory = directory;
-  }
-  @Override
-  public File getDirectory() {
-    return this.directory;
-  }
-
-  private File tarball;
-  protected void setTarball(File tarball) {
-    this.tarball = tarball;
-  }
-  @Override
-  public File getTarball() {
-    return this.tarball;
-  }
-
-  private File file;
-  protected void setFile(File file) {
-    this.file = file;
-  }
-  @Override
-  public File getFile() {
-    return this.file;
-  }
-
-  private String fileName;
-  protected void setFileName(String fileName) {
-    this.fileName = fileName;
-  }
-  @Override
-  public String getFileName() {
-    return this.fileName;
-  }
-
-  private long lastModified;
-  protected void setLastModified(long lastModified) {
-    this.lastModified = lastModified;
-  }
-  @Override
-  public long getLastModified() {
-    return this.lastModified;
-  }
-
-  private List<Descriptor> descriptors;
-  protected void setDescriptors(List<Descriptor> descriptors) {
-    this.descriptors = descriptors;
-  }
-  @Override
-  public List<Descriptor> getDescriptors() {
-    return this.descriptors == null ? new ArrayList<Descriptor>() :
-      new ArrayList<>(this.descriptors);
-  }
-
-  private Exception exception;
-  protected void setException(Exception exception) {
-    this.exception = exception;
-  }
-  @Override
-  public Exception getException() {
-    return this.exception;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorImpl.java b/src/org/torproject/descriptor/impl/DescriptorImpl.java
deleted file mode 100644
index 5625b3f..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorImpl.java
+++ /dev/null
@@ -1,337 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.Set;
-
-import org.torproject.descriptor.Descriptor;
-
-public abstract class DescriptorImpl implements Descriptor {
-
-  protected static List<Descriptor> parseDescriptors(
-      byte[] rawDescriptorBytes, String fileName,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<Descriptor> parsedDescriptors = new ArrayList<>();
-    if (rawDescriptorBytes == null) {
-      return parsedDescriptors;
-    }
-    byte[] first100Chars = new byte[Math.min(100,
-        rawDescriptorBytes.length)];
-    System.arraycopy(rawDescriptorBytes, 0, first100Chars, 0,
-        first100Chars.length);
-    String firstLines = new String(first100Chars);
-    if (firstLines.startsWith("@type network-status-consensus-3 1.") ||
-        firstLines.startsWith("@type network-status-microdesc-"
-            + "consensus-3 1.") ||
-        ((firstLines.startsWith("network-status-version 3") ||
-        firstLines.contains("\nnetwork-status-version 3")) &&
-        firstLines.contains("\nvote-status consensus\n"))) {
-      parsedDescriptors.addAll(RelayNetworkStatusConsensusImpl.
-          parseConsensuses(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type network-status-vote-3 1.")
-        || ((firstLines.startsWith("network-status-version 3\n") ||
-        firstLines.contains("\nnetwork-status-version 3\n")) &&
-        firstLines.contains("\nvote-status vote\n"))) {
-      parsedDescriptors.addAll(RelayNetworkStatusVoteImpl.
-          parseVotes(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type bridge-network-status 1.")
-        || firstLines.startsWith("r ")) {
-      parsedDescriptors.add(new BridgeNetworkStatusImpl(
-          rawDescriptorBytes, fileName, failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith(
-        "@type bridge-server-descriptor 1.")) {
-      parsedDescriptors.addAll(BridgeServerDescriptorImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type server-descriptor 1.") ||
-        firstLines.startsWith("router ") ||
-        firstLines.contains("\nrouter ")) {
-      parsedDescriptors.addAll(RelayServerDescriptorImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type bridge-extra-info 1.")) {
-      parsedDescriptors.addAll(BridgeExtraInfoDescriptorImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type extra-info 1.") ||
-        firstLines.startsWith("extra-info ") ||
-        firstLines.contains("\nextra-info ")) {
-      parsedDescriptors.addAll(RelayExtraInfoDescriptorImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type microdescriptor 1.") ||
-        firstLines.startsWith("onion-key\n") ||
-        firstLines.contains("\nonion-key\n")) {
-      parsedDescriptors.addAll(MicrodescriptorImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type bridge-pool-assignment 1.") ||
-        firstLines.startsWith("bridge-pool-assignment ") ||
-        firstLines.contains("\nbridge-pool-assignment ")) {
-      parsedDescriptors.addAll(BridgePoolAssignmentImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type dir-key-certificate-3 1.") ||
-        firstLines.startsWith("dir-key-certificate-version ") ||
-        firstLines.contains("\ndir-key-certificate-version ")) {
-      parsedDescriptors.addAll(DirectoryKeyCertificateImpl.
-          parseDescriptors(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type tordnsel 1.") ||
-        firstLines.startsWith("ExitNode ") ||
-        firstLines.contains("\nExitNode ")) {
-      parsedDescriptors.add(new ExitListImpl(rawDescriptorBytes, fileName,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type network-status-2 1.") ||
-        firstLines.startsWith("network-status-version 2\n") ||
-        firstLines.contains("\nnetwork-status-version 2\n")) {
-      parsedDescriptors.add(new RelayNetworkStatusImpl(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type directory 1.") ||
-        firstLines.startsWith("signed-directory\n") ||
-        firstLines.contains("\nsigned-directory\n")) {
-      parsedDescriptors.add(new RelayDirectoryImpl(rawDescriptorBytes,
-          failUnrecognizedDescriptorLines));
-    } else if (firstLines.startsWith("@type torperf 1.")) {
-      parsedDescriptors.addAll(TorperfResultImpl.parseTorperfResults(
-          rawDescriptorBytes, failUnrecognizedDescriptorLines));
-    } else {
-      throw new DescriptorParseException("Could not detect descriptor "
-          + "type in descriptor starting with '" + firstLines + "'.");
-    }
-    return parsedDescriptors;
-  }
-
-  protected static List<byte[]> splitRawDescriptorBytes(
-      byte[] rawDescriptorBytes, String startToken) {
-    List<byte[]> rawDescriptors = new ArrayList<>();
-    String splitToken = "\n" + startToken;
-    String ascii;
-    try {
-      ascii = new String(rawDescriptorBytes, "US-ASCII");
-    } catch (UnsupportedEncodingException e) {
-      return rawDescriptors;
-    }
-    int endAllDescriptors = rawDescriptorBytes.length,
-        startAnnotations = 0;
-    boolean containsAnnotations = ascii.startsWith("@") ||
-        ascii.contains("\n@");
-    while (startAnnotations < endAllDescriptors) {
-      int startDescriptor;
-      if (ascii.indexOf(startToken, startAnnotations) == 0) {
-        startDescriptor = startAnnotations;
-      } else {
-        startDescriptor = ascii.indexOf(splitToken, startAnnotations - 1);
-        if (startDescriptor < 0) {
-          break;
-        } else {
-          startDescriptor += 1;
-        }
-      }
-      int endDescriptor = -1;
-      if (containsAnnotations) {
-        endDescriptor = ascii.indexOf("\n@", startDescriptor);
-      }
-      if (endDescriptor < 0) {
-        endDescriptor = ascii.indexOf(splitToken, startDescriptor);
-      }
-      if (endDescriptor < 0) {
-        endDescriptor = endAllDescriptors - 1;
-      }
-      endDescriptor += 1;
-      byte[] rawDescriptor = new byte[endDescriptor - startAnnotations];
-      System.arraycopy(rawDescriptorBytes, startAnnotations,
-          rawDescriptor, 0, endDescriptor - startAnnotations);
-      startAnnotations = endDescriptor;
-      rawDescriptors.add(rawDescriptor);
-    }
-    return rawDescriptors;
-  }
-
-  protected byte[] rawDescriptorBytes;
-  @Override
-  public byte[] getRawDescriptorBytes() {
-    return this.rawDescriptorBytes;
-  }
-
-  protected boolean failUnrecognizedDescriptorLines = false;
-
-  protected List<String> unrecognizedLines;
-  @Override
-  public List<String> getUnrecognizedLines() {
-    return this.unrecognizedLines == null ? new ArrayList<String>() :
-        new ArrayList<>(this.unrecognizedLines);
-  }
-
-  protected DescriptorImpl(byte[] rawDescriptorBytes,
-      boolean failUnrecognizedDescriptorLines, boolean blankLinesAllowed)
-      throws DescriptorParseException {
-    this.rawDescriptorBytes = rawDescriptorBytes;
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-    this.cutOffAnnotations(rawDescriptorBytes);
-    this.countKeywords(rawDescriptorBytes, blankLinesAllowed);
-  }
-
-  /* Parse annotation lines from the descriptor bytes. */
-  private List<String> annotations = new ArrayList<>();
-  private void cutOffAnnotations(byte[] rawDescriptorBytes)
-      throws DescriptorParseException {
-    String ascii = new String(rawDescriptorBytes);
-    int start = 0;
-    while ((start == 0 && ascii.startsWith("@")) ||
-        (start > 0 && ascii.indexOf("\n@", start - 1) >= 0)) {
-      int end = ascii.indexOf("\n", start);
-      if (end < 0) {
-        throw new DescriptorParseException("Annotation line does not "
-            + "contain a newline.");
-      }
-      this.annotations.add(ascii.substring(start, end));
-      start = end + 1;
-    }
-    if (start > 0) {
-      int length = rawDescriptorBytes.length;
-      byte[] rawDescriptor = new byte[length - start];
-      System.arraycopy(rawDescriptorBytes, start, rawDescriptor, 0,
-        length - start);
-      this.rawDescriptorBytes = rawDescriptor;
-    }
-  }
-  @Override
-  public List<String> getAnnotations() {
-    return new ArrayList<>(this.annotations);
-  }
-
-  /* Count parsed keywords for consistency checks by subclasses. */
-  private String firstKeyword, lastKeyword;
-  private Map<String, Integer> parsedKeywords = new HashMap<>();
-  private void countKeywords(byte[] rawDescriptorBytes,
-      boolean blankLinesAllowed) throws DescriptorParseException {
-    if (rawDescriptorBytes.length == 0) {
-      throw new DescriptorParseException("Descriptor is empty.");
-    }
-    String descriptorString = new String(rawDescriptorBytes);
-    if (!blankLinesAllowed && (descriptorString.startsWith("\n") ||
-        descriptorString.contains("\n\n"))) {
-      throw new DescriptorParseException("Blank lines are not allowed.");
-    }
-    boolean skipCrypto = false;
-    Scanner s = new Scanner(descriptorString).useDelimiter("\n");
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.startsWith("-----BEGIN")) {
-        skipCrypto = true;
-      } else if (line.startsWith("-----END")) {
-        skipCrypto = false;
-      } else if (!line.isEmpty() && !line.startsWith("@") &&
-          !skipCrypto) {
-        String lineNoOpt = line.startsWith("opt ") ?
-            line.substring("opt ".length()) : line;
-        String keyword = lineNoOpt.split(" ", -1)[0];
-        if (keyword.equals("")) {
-          throw new DescriptorParseException("Illegal keyword in line '"
-              + line + "'.");
-        }
-        if (this.firstKeyword == null) {
-          this.firstKeyword = keyword;
-        }
-        lastKeyword = keyword;
-        if (parsedKeywords.containsKey(keyword)) {
-          parsedKeywords.put(keyword, parsedKeywords.get(keyword) + 1);
-        } else {
-          parsedKeywords.put(keyword, 1);
-        }
-      }
-    }
-  }
-
-  protected void checkFirstKeyword(String keyword)
-      throws DescriptorParseException {
-    if (this.firstKeyword == null ||
-        !this.firstKeyword.equals(keyword)) {
-      throw new DescriptorParseException("Keyword '" + keyword + "' must "
-          + "be contained in the first line.");
-    }
-  }
-
-  protected void checkLastKeyword(String keyword)
-      throws DescriptorParseException {
-    if (this.lastKeyword == null ||
-        !this.lastKeyword.equals(keyword)) {
-      throw new DescriptorParseException("Keyword '" + keyword + "' must "
-          + "be contained in the last line.");
-    }
-  }
-
-  protected void checkExactlyOnceKeywords(Set<String> keywords)
-      throws DescriptorParseException {
-    for (String keyword : keywords) {
-      int contained = 0;
-      if (this.parsedKeywords.containsKey(keyword)) {
-        contained = this.parsedKeywords.get(keyword);
-      }
-      if (contained != 1) {
-        throw new DescriptorParseException("Keyword '" + keyword + "' is "
-            + "contained " + contained + " times, but must be contained "
-            + "exactly once.");
-      }
-    }
-  }
-
-  protected void checkAtLeastOnceKeywords(Set<String> keywords)
-      throws DescriptorParseException {
-    for (String keyword : keywords) {
-      if (!this.parsedKeywords.containsKey(keyword)) {
-        throw new DescriptorParseException("Keyword '" + keyword + "' is "
-            + "contained 0 times, but must be contained at least once.");
-      }
-    }
-  }
-
-  protected void checkAtMostOnceKeywords(Set<String> keywords)
-      throws DescriptorParseException {
-    for (String keyword : keywords) {
-      if (this.parsedKeywords.containsKey(keyword) &&
-          this.parsedKeywords.get(keyword) > 1) {
-        throw new DescriptorParseException("Keyword '" + keyword + "' is "
-            + "contained " + this.parsedKeywords.get(keyword) + " times, "
-            + "but must be contained at most once.");
-      }
-    }
-  }
-
-  protected void checkKeywordsDependOn(Set<String> dependentKeywords,
-      String dependingKeyword) throws DescriptorParseException {
-    for (String dependentKeyword : dependentKeywords) {
-      if (this.parsedKeywords.containsKey(dependentKeyword) &&
-          !this.parsedKeywords.containsKey(dependingKeyword)) {
-        throw new DescriptorParseException("Keyword '" + dependentKeyword
-            + "' is contained, but keyword '" + dependingKeyword + "' is "
-            + "not.");
-      }
-    }
-  }
-
-  protected int getKeywordCount(String keyword) {
-    if (!this.parsedKeywords.containsKey(keyword)) {
-      return 0;
-    } else {
-      return this.parsedKeywords.get(keyword);
-    }
-  }
-
-  protected void clearParsedKeywords() {
-    this.parsedKeywords = null;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorParseException.java b/src/org/torproject/descriptor/impl/DescriptorParseException.java
deleted file mode 100644
index 0f9add2..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorParseException.java
+++ /dev/null
@@ -1,15 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-/**
- * @deprecated Replaced by
- * org.torproject.descriptor.DescriptorParseException
- */
-@Deprecated public class DescriptorParseException extends Exception {
-  private static final long serialVersionUID = 100L;
-  protected DescriptorParseException(String message) {
-    super(message);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorParserImpl.java b/src/org/torproject/descriptor/impl/DescriptorParserImpl.java
deleted file mode 100644
index 6ac53f8..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorParserImpl.java
+++ /dev/null
@@ -1,28 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.List;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorParser;
-
-public class DescriptorParserImpl implements DescriptorParser {
-
-  private boolean failUnrecognizedDescriptorLines;
-
-  @Override
-  public void setFailUnrecognizedDescriptorLines(
-      boolean failUnrecognizedDescriptorLines) {
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-  }
-
-  @Override
-  public List<Descriptor> parseDescriptors(byte[] rawDescriptorBytes,
-      String fileName) throws DescriptorParseException {
-    return DescriptorImpl.parseDescriptors(rawDescriptorBytes, fileName,
-        this.failUnrecognizedDescriptorLines);
-  }
-}
diff --git a/src/org/torproject/descriptor/impl/DescriptorReaderImpl.java b/src/org/torproject/descriptor/impl/DescriptorReaderImpl.java
deleted file mode 100644
index 8da88e9..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorReaderImpl.java
+++ /dev/null
@@ -1,364 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.io.BufferedInputStream;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.Stack;
-import java.util.TreeMap;
-
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
-import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
-import org.apache.commons.compress.compressors.xz.XZCompressorInputStream;
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorFile;
-import org.torproject.descriptor.DescriptorParser;
-import org.torproject.descriptor.DescriptorReader;
-
-public class DescriptorReaderImpl implements DescriptorReader {
-
-  private boolean hasStartedReading = false;
-
-  private List<File> directories = new ArrayList<>();
-  @Override
-  public void addDirectory(File directory) {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to read.");
-    }
-    this.directories.add(directory);
-  }
-
-  private List<File> tarballs = new ArrayList<>();
-  @Override
-  public void addTarball(File tarball) {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to read.");
-    }
-    this.tarballs.add(tarball);
-  }
-
-  private File historyFile;
-  @Override
-  public void setExcludeFiles(File historyFile) {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to read.");
-    }
-    this.historyFile = historyFile;
-  }
-
-  private SortedMap<String, Long> excludedFiles;
-  @Override
-  public void setExcludedFiles(SortedMap<String, Long> excludedFiles) {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to read.");
-    }
-    this.excludedFiles = excludedFiles;
-  }
-
-  @Override
-  public SortedMap<String, Long> getExcludedFiles() {
-    if (this.reader == null || !this.reader.hasFinishedReading) {
-      throw new IllegalStateException("Operation is not permitted before "
-          + "finishing to read.");
-    }
-    return new TreeMap<>(this.reader.excludedFilesAfter);
-  }
-
-  @Override
-  public SortedMap<String, Long> getParsedFiles() {
-    if (this.reader == null || !this.reader.hasFinishedReading) {
-      throw new IllegalStateException("Operation is not permitted before "
-          + "finishing to read.");
-    }
-    return new TreeMap<>(this.reader.parsedFilesAfter);
-  }
-
-  private boolean failUnrecognizedDescriptorLines = false;
-  @Override
-  public void setFailUnrecognizedDescriptorLines() {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to read.");
-    }
-    this.failUnrecognizedDescriptorLines = true;
-  }
-
-  private Integer maxDescriptorFilesInQueue = null;
-  @Override
-  public void setMaxDescriptorFilesInQueue(int max) {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Reconfiguration is not permitted "
-          + "after starting to read.");
-    }
-    this.maxDescriptorFilesInQueue = max;
-  }
-
-  private DescriptorReaderRunnable reader;
-  @Override
-  public Iterator<DescriptorFile> readDescriptors() {
-    if (this.hasStartedReading) {
-      throw new IllegalStateException("Initiating reading is only "
-          + "permitted once.");
-    }
-    this.hasStartedReading = true;
-    BlockingIteratorImpl<DescriptorFile> descriptorQueue =
-        this.maxDescriptorFilesInQueue == null
-        ? new BlockingIteratorImpl<DescriptorFile>()
-        : new BlockingIteratorImpl<DescriptorFile>(
-        this.maxDescriptorFilesInQueue);
-    this.reader = new DescriptorReaderRunnable(this.directories,
-        this.tarballs, descriptorQueue, this.historyFile,
-        this.excludedFiles, this.failUnrecognizedDescriptorLines);
-    new Thread(this.reader).start();
-    return descriptorQueue;
-  }
-
-  private static class DescriptorReaderRunnable implements Runnable {
-    private List<File> directories;
-    private List<File> tarballs;
-    private BlockingIteratorImpl<DescriptorFile> descriptorQueue;
-    private File historyFile;
-    private SortedMap<String, Long> excludedFilesBefore = new TreeMap<>(),
-        excludedFilesAfter = new TreeMap<>(),
-        parsedFilesAfter = new TreeMap<>();
-    private DescriptorParser descriptorParser;
-    private boolean hasFinishedReading = false;
-    private DescriptorReaderRunnable(List<File> directories,
-        List<File> tarballs,
-        BlockingIteratorImpl<DescriptorFile> descriptorQueue,
-        File historyFile, SortedMap<String, Long> excludedFiles,
-        boolean failUnrecognizedDescriptorLines) {
-      this.directories = directories;
-      this.tarballs = tarballs;
-      this.descriptorQueue = descriptorQueue;
-      this.historyFile = historyFile;
-      if (excludedFiles != null) {
-        this.excludedFilesBefore = excludedFiles;
-      }
-      this.descriptorParser = new DescriptorParserImpl();
-      this.descriptorParser.setFailUnrecognizedDescriptorLines(
-          failUnrecognizedDescriptorLines);
-    }
-    public void run() {
-      try {
-        this.readOldHistory();
-        this.readDescriptors();
-        this.readTarballs();
-        this.hasFinishedReading = true;
-      } catch (Throwable t) {
-        /* We're usually not writing to stdout or stderr, but we shouldn't
-         * stay quiet about this potential bug.  If we were to switch to a
-         * logging API, this would qualify as ERROR. */
-        System.err.println("Bug: uncaught exception or error while "
-            + "reading descriptors:");
-        t.printStackTrace();
-      } finally {
-        this.descriptorQueue.setOutOfDescriptors();
-      }
-      if (this.hasFinishedReading) {
-        this.writeNewHistory();
-      }
-    }
-    private void readOldHistory() {
-      if (this.historyFile == null) {
-        return;
-      }
-      try {
-        BufferedReader br = new BufferedReader(new FileReader(
-            this.historyFile));
-        String line;
-        while ((line = br.readLine()) != null) {
-          if (!line.contains(" ")) {
-            /* TODO Handle this problem? */
-            continue;
-          }
-          long lastModifiedMillis = Long.parseLong(line.substring(0,
-              line.indexOf(" ")));
-          String absolutePath = line.substring(line.indexOf(" ") + 1);
-          this.excludedFilesBefore.put(absolutePath, lastModifiedMillis);
-        }
-        br.close();
-      } catch (IOException e) {
-        /* TODO Handle this exception. */
-      } catch (NumberFormatException e) {
-        /* TODO Handle this exception. */
-      }
-    }
-    private void writeNewHistory() {
-      if (this.historyFile == null) {
-        return;
-      }
-      try {
-        if (this.historyFile.getParentFile() != null) {
-          this.historyFile.getParentFile().mkdirs();
-        }
-        BufferedWriter bw = new BufferedWriter(new FileWriter(
-            this.historyFile));
-        SortedMap<String, Long> newHistory = new TreeMap<>();
-        newHistory.putAll(this.excludedFilesAfter);
-        newHistory.putAll(this.parsedFilesAfter);
-        for (Map.Entry<String, Long> e : newHistory.entrySet()) {
-          String absolutePath = e.getKey();
-          long lastModifiedMillis = e.getValue();
-          bw.write(String.valueOf(lastModifiedMillis) + " " + absolutePath
-              + "\n");
-        }
-        bw.close();
-      } catch (IOException e) {
-        /* TODO Handle this exception. */
-      }
-    }
-    private void readDescriptors() {
-      for (File directory : this.directories) {
-        if (!directory.exists() || !directory.isDirectory()) {
-          continue;
-        }
-        Stack<File> files = new Stack<>();
-        files.add(directory);
-        boolean abortReading = false;
-        while (!abortReading && !files.isEmpty()) {
-          File file = files.pop();
-          if (file.isDirectory()) {
-            files.addAll(Arrays.asList(file.listFiles()));
-          } else if (file.getName().endsWith(".tar") ||
-              file.getName().endsWith(".tar.bz2") ||
-              file.getName().endsWith(".tar.xz")) {
-            this.tarballs.add(file);
-          } else {
-            String absolutePath = file.getAbsolutePath();
-            long lastModifiedMillis = file.lastModified();
-            if (this.excludedFilesBefore.containsKey(absolutePath) &&
-                this.excludedFilesBefore.get(absolutePath) ==
-                lastModifiedMillis) {
-              this.excludedFilesAfter.put(absolutePath,
-                  lastModifiedMillis);
-              continue;
-            }
-            this.parsedFilesAfter.put(absolutePath, lastModifiedMillis);
-            DescriptorFileImpl descriptorFile = new DescriptorFileImpl();
-            try {
-              descriptorFile.setDirectory(directory);
-              descriptorFile.setFile(file);
-              descriptorFile.setFileName(file.getAbsolutePath());
-              descriptorFile.setLastModified(lastModifiedMillis);
-              descriptorFile.setDescriptors(this.readFile(file));
-            } catch (DescriptorParseException e) {
-              descriptorFile.setException(e);
-            } catch (IOException e) {
-              descriptorFile.setException(e);
-              abortReading = true;
-            }
-            this.descriptorQueue.add(descriptorFile);
-          }
-        }
-      }
-    }
-    private void readTarballs() {
-      List<File> files = new ArrayList<>(this.tarballs);
-      boolean abortReading = false;
-      while (!abortReading && !files.isEmpty()) {
-        File tarball = files.remove(0);
-        if (!tarball.getName().endsWith(".tar") &&
-            !tarball.getName().endsWith(".tar.bz2") &&
-            !tarball.getName().endsWith(".tar.xz")) {
-          continue;
-        }
-        String absolutePath = tarball.getAbsolutePath();
-        long lastModifiedMillis = tarball.lastModified();
-        if (this.excludedFilesBefore.containsKey(absolutePath) &&
-            this.excludedFilesBefore.get(absolutePath) ==
-            lastModifiedMillis) {
-          this.excludedFilesAfter.put(absolutePath, lastModifiedMillis);
-          continue;
-        }
-        this.parsedFilesAfter.put(absolutePath, lastModifiedMillis);
-        try {
-          FileInputStream in = new FileInputStream(tarball);
-          if (in.available() > 0) {
-            TarArchiveInputStream tais = null;
-            if (tarball.getName().endsWith(".tar.bz2")) {
-              tais = new TarArchiveInputStream(
-                  new BZip2CompressorInputStream(in));
-            } else if (tarball.getName().endsWith(".tar.xz")) {
-              tais = new TarArchiveInputStream(
-                  new XZCompressorInputStream(in));
-            } else if (tarball.getName().endsWith(".tar")) {
-              tais = new TarArchiveInputStream(in);
-            }
-            BufferedInputStream bis = new BufferedInputStream(tais);
-            TarArchiveEntry tae = null;
-            while ((tae = tais.getNextTarEntry()) != null) {
-              if (tae.isDirectory()) {
-                continue;
-              }
-              DescriptorFileImpl descriptorFile =
-                  new DescriptorFileImpl();
-              descriptorFile.setTarball(tarball);
-              descriptorFile.setFileName(tae.getName());
-              descriptorFile.setLastModified(tae.getLastModifiedDate().
-                  getTime());
-              ByteArrayOutputStream baos = new ByteArrayOutputStream();
-              int len;
-              byte[] data = new byte[1024];
-              while ((len = bis.read(data, 0, 1024)) >= 0) {
-                baos.write(data, 0, len);
-              }
-              byte[] rawDescriptorBytes = baos.toByteArray();
-              if (rawDescriptorBytes.length < 1) {
-                continue;
-              }
-              try {
-                String fileName = tae.getName().substring(
-                    tae.getName().lastIndexOf("/") + 1);
-                List<Descriptor> parsedDescriptors =
-                    this.descriptorParser.parseDescriptors(
-                    rawDescriptorBytes, fileName);
-                descriptorFile.setDescriptors(parsedDescriptors);
-              } catch (DescriptorParseException e) {
-                descriptorFile.setException(e);
-              }
-              this.descriptorQueue.add(descriptorFile);
-            }
-          }
-        } catch (IOException e) {
-          abortReading = true;
-        }
-      }
-    }
-    private List<Descriptor> readFile(File file) throws IOException,
-        DescriptorParseException {
-      FileInputStream fis = new FileInputStream(file);
-      BufferedInputStream bis = new BufferedInputStream(fis);
-      ByteArrayOutputStream baos = new ByteArrayOutputStream();
-      int len;
-      byte[] data = new byte[1024];
-      while ((len = bis.read(data, 0, 1024)) >= 0) {
-        baos.write(data, 0, len);
-      }
-      bis.close();
-      byte[] rawDescriptorBytes = baos.toByteArray();
-      return this.descriptorParser.parseDescriptors(rawDescriptorBytes,
-          file.getName());
-    }
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DescriptorRequestImpl.java b/src/org/torproject/descriptor/impl/DescriptorRequestImpl.java
deleted file mode 100644
index 0238f24..0000000
--- a/src/org/torproject/descriptor/impl/DescriptorRequestImpl.java
+++ /dev/null
@@ -1,114 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.List;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorRequest;
-
-public class DescriptorRequestImpl implements DescriptorRequest {
-
-  private String requestedResource;
-  protected void setRequestedResource(String requestedResource) {
-    this.requestedResource = requestedResource;
-  }
-  protected String getRequestedResource() {
-    return this.requestedResource;
-  }
-
-  private String descriptorType;
-  protected void setDescriptorType(String descriptorType) {
-    this.descriptorType = descriptorType;
-  }
-  protected String getDescriptorType() {
-    return this.descriptorType;
-  }
-
-  private byte[] responseBytes;
-  protected byte[] getResponseBytes() {
-    return this.responseBytes;
-  }
-  protected void setResponseBytes(byte[] responseBytes) {
-    this.responseBytes = responseBytes;
-  }
-
-  private String requestUrl;
-  @Override
-  public String getRequestUrl() {
-    return this.requestUrl;
-  }
-
-  private String directoryNickname;
-  protected void setDirectoryNickname(String directoryNickname) {
-    this.directoryNickname = directoryNickname;
-  }
-  @Override
-  public String getDirectoryNickname() {
-    return this.directoryNickname;
-  }
-
-  private int responseCode;
-  protected void setResponseCode(int responseCode) {
-    this.responseCode = responseCode;
-  }
-  @Override
-  public int getResponseCode() {
-    return this.responseCode;
-  }
-
-  private long requestStart;
-  protected void setRequestStart(long requestStart) {
-    this.requestStart = requestStart;
-  }
-  @Override
-  public long getRequestStart() {
-    return this.requestStart;
-  }
-
-  private long requestEnd;
-  protected void setRequestEnd(long requestEnd) {
-    this.requestEnd = requestEnd;
-  }
-  @Override
-  public long getRequestEnd() {
-    return this.requestEnd;
-  }
-
-  private boolean connectTimeoutHasExpired;
-  @Override
-  public boolean connectTimeoutHasExpired() {
-    return this.connectTimeoutHasExpired;
-  }
-
-  private boolean readTimeoutHasExpired;
-  @Override
-  public boolean readTimeoutHasExpired() {
-    return this.readTimeoutHasExpired;
-  }
-
-  private boolean globalTimeoutHasExpired;
-  @Override
-  public boolean globalTimeoutHasExpired() {
-    return this.globalTimeoutHasExpired;
-  }
-
-  private List<Descriptor> descriptors;
-  protected void setDescriptors(List<Descriptor> descriptors) {
-    this.descriptors = descriptors;
-  }
-  @Override
-  public List<Descriptor> getDescriptors() {
-    return this.descriptors;
-  }
-
-  private Exception exception;
-  protected void setException(Exception exception) {
-    this.exception = exception;
-  }
-  @Override
-  public Exception getException() {
-    return this.exception;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DirSourceEntryImpl.java b/src/org/torproject/descriptor/impl/DirSourceEntryImpl.java
deleted file mode 100644
index fb2f5ad..0000000
--- a/src/org/torproject/descriptor/impl/DirSourceEntryImpl.java
+++ /dev/null
@@ -1,218 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Scanner;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.DirSourceEntry;
-
-public class DirSourceEntryImpl implements DirSourceEntry {
-
-  private byte[] dirSourceEntryBytes;
-  @Override
-  public byte[] getDirSourceEntryBytes() {
-    return this.dirSourceEntryBytes;
-  }
-
-  private boolean failUnrecognizedDescriptorLines;
-  private List<String> unrecognizedLines;
-  protected List<String> getAndClearUnrecognizedLines() {
-    List<String> lines = this.unrecognizedLines;
-    this.unrecognizedLines = null;
-    return lines;
-  }
-
-  protected DirSourceEntryImpl(byte[] dirSourceEntryBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    this.dirSourceEntryBytes = dirSourceEntryBytes;
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-    this.initializeKeywords();
-    this.parseDirSourceEntryBytes();
-    this.checkAndClearKeywords();
-  }
-
-  private SortedSet<String> exactlyOnceKeywords, atMostOnceKeywords;
-  private void initializeKeywords() {
-    this.exactlyOnceKeywords = new TreeSet<>();
-    this.exactlyOnceKeywords.add("dir-source");
-    this.exactlyOnceKeywords.add("vote-digest");
-    this.atMostOnceKeywords = new TreeSet<>();
-    this.atMostOnceKeywords.add("contact");
-  }
-
-  private void parsedExactlyOnceKeyword(String keyword)
-      throws DescriptorParseException {
-    if (!this.exactlyOnceKeywords.contains(keyword)) {
-      throw new DescriptorParseException("Duplicate '" + keyword
-          + "' line in dir-source.");
-    }
-    this.exactlyOnceKeywords.remove(keyword);
-  }
-
-  private void parsedAtMostOnceKeyword(String keyword)
-      throws DescriptorParseException {
-    if (!this.atMostOnceKeywords.contains(keyword)) {
-      throw new DescriptorParseException("Duplicate " + keyword + "line "
-          + "in dir-source.");
-    }
-    this.atMostOnceKeywords.remove(keyword);
-  }
-
-  private void checkAndClearKeywords() throws DescriptorParseException {
-    if (!this.exactlyOnceKeywords.isEmpty()) {
-      throw new DescriptorParseException("dir-source does not contain a '"
-          + this.exactlyOnceKeywords.first() + "' line.");
-    }
-    this.exactlyOnceKeywords = null;
-    this.atMostOnceKeywords = null;
-  }
-
-  private void parseDirSourceEntryBytes()
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.dirSourceEntryBytes)).
-        useDelimiter("\n");
-    boolean skipCrypto = false;
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split(" ");
-      switch (parts[0]) {
-      case "dir-source":
-        this.parseDirSourceLine(line);
-        break;
-      case "contact":
-        this.parseContactLine(line);
-        break;
-      case "vote-digest":
-        this.parseVoteDigestLine(line);
-        break;
-      case "-----BEGIN":
-        skipCrypto = true;
-        break;
-      case "-----END":
-        skipCrypto = false;
-        break;
-      default:
-        if (!skipCrypto) {
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in dir-source entry.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  private void parseDirSourceLine(String line)
-      throws DescriptorParseException {
-    this.parsedExactlyOnceKeyword("dir-source");
-    String[] parts = line.split("[ \t]+");
-    if (parts.length != 7) {
-      throw new DescriptorParseException("Invalid line '" + line + "'.");
-    }
-    String nickname = parts[1];
-    if (nickname.endsWith("-legacy")) {
-      nickname = nickname.substring(0, nickname.length()
-          - "-legacy".length());
-      this.isLegacy = true;
-      this.parsedExactlyOnceKeyword("vote-digest");
-    }
-    this.nickname = ParseHelper.parseNickname(line, nickname);
-    this.identity = ParseHelper.parseTwentyByteHexString(line, parts[2]);
-    if (parts[3].length() < 1) {
-      throw new DescriptorParseException("Illegal hostname in '" + line
-          + "'.");
-    }
-    this.hostname = parts[3];
-    this.ip = ParseHelper.parseIpv4Address(line, parts[4]);
-    this.dirPort = ParseHelper.parsePort(line, parts[5]);
-    this.orPort = ParseHelper.parsePort(line, parts[6]);
-  }
-
-  private void parseContactLine(String line)
-      throws DescriptorParseException {
-    this.parsedAtMostOnceKeyword("contact");
-    if (line.length() > "contact ".length()) {
-      this.contactLine = line.substring("contact ".length());
-    } else {
-      this.contactLine = "";
-    }
-  }
-
-  private void parseVoteDigestLine(String line)
-      throws DescriptorParseException {
-    this.parsedExactlyOnceKeyword("vote-digest");
-    String[] parts = line.split("[ \t]+");
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Invalid line '" + line + "'.");
-    }
-    this.voteDigest = ParseHelper.parseTwentyByteHexString(line,
-        parts[1]);
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String identity;
-  @Override
-  public String getIdentity() {
-    return this.identity;
-  }
-
-  private boolean isLegacy;
-  @Override
-  public boolean isLegacy() {
-    return this.isLegacy;
-  }
-
-  private String hostname;
-  @Override
-  public String getHostname() {
-    return this.hostname;
-  }
-
-  private String ip;
-  @Override
-  public String getIp() {
-    return this.ip;
-  }
-
-  private int dirPort;
-  @Override
-  public int getDirPort() {
-    return this.dirPort;
-  }
-
-  private int orPort;
-  @Override
-  public int getOrPort() {
-    return this.orPort;
-  }
-
-  private String contactLine;
-  @Override
-  public String getContactLine() {
-    return this.contactLine;
-  }
-
-  private String voteDigest;
-  @Override
-  public String getVoteDigest() {
-    return this.voteDigest;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DirectoryDownloader.java b/src/org/torproject/descriptor/impl/DirectoryDownloader.java
deleted file mode 100644
index a27ed76..0000000
--- a/src/org/torproject/descriptor/impl/DirectoryDownloader.java
+++ /dev/null
@@ -1,104 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.BufferedInputStream;
-import java.io.ByteArrayOutputStream;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.util.zip.InflaterInputStream;
-
-import org.torproject.descriptor.DescriptorParser;
-import org.torproject.descriptor.DescriptorSourceFactory;
-
-/* Download descriptors from one directory authority or mirror.  First,
- * ask the coordinator thread to create a request, run it, and deliver
- * the response.  Repeat until the coordinator thread says there are no
- * further requests to make. */
-public class DirectoryDownloader implements Runnable {
-
-  private String nickname;
-  private String ipPort;
-  private DescriptorParser descriptorParser;
-  protected DirectoryDownloader(String nickname, String ip, int dirPort) {
-    this.nickname = nickname;
-    this.ipPort = ip + ":" + String.valueOf(dirPort);
-    this.descriptorParser =
-        DescriptorSourceFactory.createDescriptorParser();
-  }
-
-  private DownloadCoordinator downloadCoordinator;
-  protected void setDownloadCoordinator(
-      DownloadCoordinator downloadCoordinator) {
-    this.downloadCoordinator = downloadCoordinator;
-  }
-
-  private long connectTimeout;
-  protected void setConnectTimeout(long connectTimeout) {
-    this.connectTimeout = connectTimeout;
-  }
-
-  private long readTimeout;
-  protected void setReadTimeout(long readTimeout) {
-    this.readTimeout = readTimeout;
-  }
-
-  protected void setFailUnrecognizedDescriptorLines(
-      boolean failUnrecognizedDescriptorLines) {
-    this.descriptorParser.setFailUnrecognizedDescriptorLines(
-        failUnrecognizedDescriptorLines);
-  }
-
-  @Override
-  public void run() {
-    boolean keepRunning = true;
-    do {
-      DescriptorRequestImpl request =
-          this.downloadCoordinator.createRequest(this.nickname);
-      if (request != null) {
-        String url = "http://"; + this.ipPort
-            + request.getRequestedResource();
-        request.setRequestStart(System.currentTimeMillis());
-        HttpURLConnection huc = null;
-        try {
-          URL u = new URL(url);
-          huc = (HttpURLConnection) u.openConnection();
-          huc.setConnectTimeout((int) this.connectTimeout);
-          huc.setReadTimeout((int) this.readTimeout);
-          huc.setRequestMethod("GET");
-          huc.connect();
-          int responseCode = huc.getResponseCode();
-          request.setResponseCode(responseCode);
-          if (responseCode == 200) {
-            BufferedInputStream in = new BufferedInputStream(
-                new InflaterInputStream(huc.getInputStream()));
-            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-            int len;
-            byte[] data = new byte[8192];
-            while ((len = in.read(data, 0, 8192)) >= 0) {
-              baos.write(data, 0, len);
-            }
-            in.close();
-            byte[] responseBytes = baos.toByteArray();
-            request.setResponseBytes(responseBytes);
-            request.setRequestEnd(System.currentTimeMillis());
-            request.setDescriptors(this.descriptorParser.parseDescriptors(
-                responseBytes, null));
-          }
-        } catch (Exception e) {
-          request.setException(e);
-          if (huc != null) {
-            huc.disconnect();
-          }
-          /* Stop downloading from this directory if there are any
-           * problems, e.g., refused connections. */
-          keepRunning = false;
-        }
-        this.downloadCoordinator.deliverResponse(request);
-      } else {
-        keepRunning = false;
-      }
-    } while (keepRunning);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DirectoryKeyCertificateImpl.java b/src/org/torproject/descriptor/impl/DirectoryKeyCertificateImpl.java
deleted file mode 100644
index b62fc8e..0000000
--- a/src/org/torproject/descriptor/impl/DirectoryKeyCertificateImpl.java
+++ /dev/null
@@ -1,308 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.DirectoryKeyCertificate;
-
-/* TODO Add test class. */
-
-public class DirectoryKeyCertificateImpl extends DescriptorImpl
-    implements DirectoryKeyCertificate {
-
-  protected static List<DirectoryKeyCertificate> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<DirectoryKeyCertificate> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DirectoryKeyCertificateImpl.splitRawDescriptorBytes(
-            descriptorsBytes, "dir-key-certificate-version ");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      DirectoryKeyCertificate parsedDescriptor =
-          new DirectoryKeyCertificateImpl(descriptorBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected DirectoryKeyCertificateImpl(byte[] rawDescriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(rawDescriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.parseDescriptorBytes();
-    this.calculateDigest();
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
-        "dir-key-certificate-version,fingerprint,dir-identity-key,"
-        + "dir-key-published,dir-key-expires,dir-signing-key,"
-        + "dir-key-certification").split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
-        "dir-address,dir-key-crosscert").split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkFirstKeyword("dir-key-certificate-version");
-    this.checkLastKeyword("dir-key-certification");
-    this.clearParsedKeywords();
-  }
-
-  private void parseDescriptorBytes() throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
-        useDelimiter("\n");
-    String nextCrypto = "";
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "dir-key-certificate-version":
-        this.parseDirKeyCertificateVersionLine(line, parts);
-        break;
-      case "dir-address":
-        this.parseDirAddressLine(line, parts);
-        break;
-      case "fingerprint":
-        this.parseFingerprintLine(line, parts);
-        break;
-      case "dir-identity-key":
-        this.parseDirIdentityKeyLine(line, parts);
-        nextCrypto = "dir-identity-key";
-        break;
-      case "dir-key-published":
-        this.parseDirKeyPublishedLine(line, parts);
-        break;
-      case "dir-key-expires":
-        this.parseDirKeyExpiresLine(line, parts);
-        break;
-      case "dir-signing-key":
-        this.parseDirSigningKeyLine(line, parts);
-        nextCrypto = "dir-signing-key";
-        break;
-      case "dir-key-crosscert":
-        this.parseDirKeyCrosscertLine(line, parts);
-        nextCrypto = "dir-key-crosscert";
-        break;
-      case "dir-key-certification":
-        this.parseDirKeyCertificationLine(line, parts);
-        nextCrypto = "dir-key-certification";
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        switch (nextCrypto) {
-        case "dir-identity-key":
-          this.dirIdentityKey = cryptoString;
-          break;
-        case "dir-signing-key":
-          this.dirSigningKey = cryptoString;
-          break;
-        case "dir-key-crosscert":
-          this.dirKeyCrosscert = cryptoString;
-          break;
-        case "dir-key-certification":
-          this.dirKeyCertification = cryptoString;
-          break;
-        default:
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in directory key certificate.");
-        }
-        nextCrypto = "";
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else {
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in directory key certificate.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  private void parseDirKeyCertificateVersionLine(String line,
-      String[] parts) throws DescriptorParseException {
-    if (!line.equals("dir-key-certificate-version 3")) {
-      throw new DescriptorParseException("Illegal directory key "
-          + "certificate version number in line '" + line + "'.");
-    }
-    this.dirKeyCertificateVersion = 3;
-  }
-
-  private void parseDirAddressLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2 || parts[1].split(":").length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in directory key certificate.");
-    }
-    this.address = ParseHelper.parseIpv4Address(line,
-        parts[1].split(":")[0]);
-    this.port = ParseHelper.parsePort(line, parts[1].split(":")[1]);
-  }
-
-  private void parseFingerprintLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in directory key certificate.");
-    }
-    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
-        parts[1]);
-  }
-
-  private void parseDirIdentityKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-identity-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseDirKeyPublishedLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.dirKeyPublishedMillis = ParseHelper.parseTimestampAtIndex(line,
-        parts, 1, 2);
-  }
-
-  private void parseDirKeyExpiresLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.dirKeyExpiresMillis = ParseHelper.parseTimestampAtIndex(line,
-        parts, 1, 2);
-  }
-
-  private void parseDirSigningKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-signing-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseDirKeyCrosscertLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-key-crosscert")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseDirKeyCertificationLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-key-certification")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "dir-key-certificate-version ";
-      String sigToken = "\ndir-key-certification\n";
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, sig - start);
-        this.certificateDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-1").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.certificateDigest == null) {
-      throw new DescriptorParseException("Could not calculate "
-          + "certificate digest.");
-    }
-  }
-
-  private int dirKeyCertificateVersion;
-  @Override
-  public int getDirKeyCertificateVersion() {
-    return this.dirKeyCertificateVersion;
-  }
-
-  private String address;
-  @Override
-  public String getAddress() {
-    return this.address;
-  }
-
-  private int port = -1;
-  @Override
-  public int getPort() {
-    return this.port;
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private String dirIdentityKey;
-  @Override
-  public String getDirIdentityKey() {
-    return this.dirIdentityKey;
-  }
-
-  private long dirKeyPublishedMillis;
-  @Override
-  public long getDirKeyPublishedMillis() {
-    return this.dirKeyPublishedMillis;
-  }
-
-  private long dirKeyExpiresMillis;
-  @Override
-  public long getDirKeyExpiresMillis() {
-    return this.dirKeyExpiresMillis;
-  }
-
-  private String dirSigningKey;
-  @Override
-  public String getDirSigningKey() {
-    return this.dirSigningKey;
-  }
-
-  private String dirKeyCrosscert;
-  @Override
-  public String getDirKeyCrosscert() {
-    return this.dirKeyCrosscert;
-  }
-
-  private String dirKeyCertification;
-  @Override
-  public String getDirKeyCertification() {
-    return this.dirKeyCertification;
-  }
-
-  private String certificateDigest;
-  @Override
-  public String getCertificateDigest() {
-    return this.certificateDigest;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DirectorySignatureImpl.java b/src/org/torproject/descriptor/impl/DirectorySignatureImpl.java
deleted file mode 100644
index a955f62..0000000
--- a/src/org/torproject/descriptor/impl/DirectorySignatureImpl.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Scanner;
-
-import org.torproject.descriptor.DirectorySignature;
-
-public class DirectorySignatureImpl implements DirectorySignature {
-
-  private byte[] directorySignatureBytes;
-
-  private boolean failUnrecognizedDescriptorLines;
-  private List<String> unrecognizedLines;
-  protected List<String> getAndClearUnrecognizedLines() {
-    List<String> lines = this.unrecognizedLines;
-    this.unrecognizedLines = null;
-    return lines;
-  }
-
-  protected DirectorySignatureImpl(byte[] directorySignatureBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    this.directorySignatureBytes = directorySignatureBytes;
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-    this.parseDirectorySignatureBytes();
-  }
-
-  private void parseDirectorySignatureBytes()
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.directorySignatureBytes)).
-        useDelimiter("\n");
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split(" ", -1);
-      String keyword = parts[0];
-      switch (keyword) {
-      case "directory-signature":
-        int algorithmOffset = 0;
-        switch (parts.length) {
-        case 4:
-          this.algorithm = parts[1];
-          algorithmOffset = 1;
-          break;
-        case 3:
-          break;
-        default:
-          throw new DescriptorParseException("Illegal line '" + line
-              + "'.");
-        }
-        this.identity = ParseHelper.parseHexString(line,
-            parts[1 + algorithmOffset]);
-        this.signingKeyDigest = ParseHelper.parseHexString(
-            line, parts[2 + algorithmOffset]);
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        this.signature = cryptoString;
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else {
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in dir-source entry.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  static final String DEFAULT_ALGORITHM = "sha1";
-
-  private String algorithm;
-  @Override
-  public String getAlgorithm() {
-    return this.algorithm == null ? DEFAULT_ALGORITHM : this.algorithm;
-  }
-
-  private String identity;
-  @Override
-  public String getIdentity() {
-    return this.identity;
-  }
-
-  private String signingKeyDigest;
-  @Override
-  public String getSigningKeyDigest() {
-    return this.signingKeyDigest;
-  }
-
-  private String signature;
-  @Override
-  public String getSignature() {
-    return this.signature;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/DownloadCoordinator.java b/src/org/torproject/descriptor/impl/DownloadCoordinator.java
deleted file mode 100644
index 72cfeae..0000000
--- a/src/org/torproject/descriptor/impl/DownloadCoordinator.java
+++ /dev/null
@@ -1,10 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-public interface DownloadCoordinator {
-
-  public DescriptorRequestImpl createRequest(String nickname);
-
-  public void deliverResponse(DescriptorRequestImpl request);
-}
diff --git a/src/org/torproject/descriptor/impl/DownloadCoordinatorImpl.java b/src/org/torproject/descriptor/impl/DownloadCoordinatorImpl.java
deleted file mode 100644
index a8e3731..0000000
--- a/src/org/torproject/descriptor/impl/DownloadCoordinatorImpl.java
+++ /dev/null
@@ -1,298 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorRequest;
-import org.torproject.descriptor.DirSourceEntry;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-
-/* TODO This whole download logic is a mess and needs a cleanup. */
-public class DownloadCoordinatorImpl implements DownloadCoordinator {
-
-  private BlockingIteratorImpl<DescriptorRequest> descriptorQueue =
-      new BlockingIteratorImpl<>();
-  protected Iterator<DescriptorRequest> getDescriptorQueue() {
-    return this.descriptorQueue;
-  }
-
-  private SortedSet<String> runningDirectories;
-  private SortedMap<String, DirectoryDownloader> directoryAuthorities;
-  private SortedMap<String, DirectoryDownloader> directoryMirrors;
-  private boolean downloadConsensusFromAllAuthorities;
-  private boolean includeCurrentReferencedVotes;
-  private long connectTimeoutMillis;
-  private long readTimeoutMillis;
-  private long globalTimeoutMillis;
-  private boolean failUnrecognizedDescriptorLines;
-
-  protected DownloadCoordinatorImpl(
-      SortedMap<String, DirectoryDownloader> directoryAuthorities,
-      SortedMap<String, DirectoryDownloader> directoryMirrors,
-      boolean downloadConsensus,
-      boolean downloadConsensusFromAllAuthorities,
-      Set<String> downloadVotes, boolean includeCurrentReferencedVotes,
-      long connectTimeoutMillis, long readTimeoutMillis,
-      long globalTimeoutMillis, boolean failUnrecognizedDescriptorLines) {
-    this.directoryAuthorities = directoryAuthorities;
-    this.directoryMirrors = directoryMirrors;
-    this.runningDirectories = new TreeSet<>();
-    this.runningDirectories.addAll(directoryAuthorities.keySet());
-    this.runningDirectories.addAll(directoryMirrors.keySet());
-    this.missingConsensus = downloadConsensus;
-    this.downloadConsensusFromAllAuthorities =
-        downloadConsensusFromAllAuthorities;
-    this.missingVotes = downloadVotes;
-    this.includeCurrentReferencedVotes = includeCurrentReferencedVotes;
-    this.connectTimeoutMillis = connectTimeoutMillis;
-    this.readTimeoutMillis = readTimeoutMillis;
-    this.globalTimeoutMillis = globalTimeoutMillis;
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-    if (this.directoryMirrors.isEmpty() &&
-        this.directoryAuthorities.isEmpty()) {
-      this.descriptorQueue.setOutOfDescriptors();
-      /* TODO Should we say anything if we don't have any directories
-       * configured? */
-    } else {
-      GlobalTimer globalTimer = new GlobalTimer(this.globalTimeoutMillis,
-          this);
-      this.globalTimerThread = new Thread(globalTimer);
-      this.globalTimerThread.start();
-      for (DirectoryDownloader directoryMirror :
-          this.directoryMirrors.values()) {
-        directoryMirror.setDownloadCoordinator(this);
-        directoryMirror.setConnectTimeout(this.connectTimeoutMillis);
-        directoryMirror.setReadTimeout(this.readTimeoutMillis);
-        directoryMirror.setFailUnrecognizedDescriptorLines(
-            this.failUnrecognizedDescriptorLines);
-        new Thread(directoryMirror).start();
-      }
-      for (DirectoryDownloader directoryAuthority :
-          this.directoryAuthorities.values()) {
-        directoryAuthority.setDownloadCoordinator(this);
-        directoryAuthority.setConnectTimeout(this.connectTimeoutMillis);
-        directoryAuthority.setReadTimeout(this.readTimeoutMillis);
-        directoryAuthority.setFailUnrecognizedDescriptorLines(
-            this.failUnrecognizedDescriptorLines);
-        new Thread(directoryAuthority).start();
-      }
-    }
-  }
-
-  /* Interrupt all downloads if the total download time exceeds a given
-   * time. */
-  private Thread globalTimerThread;
-  private static class GlobalTimer implements Runnable {
-    private long timeoutMillis;
-    private DownloadCoordinatorImpl downloadCoordinator;
-    private GlobalTimer(long timeoutMillis,
-        DownloadCoordinatorImpl downloadCoordinator) {
-      this.timeoutMillis = timeoutMillis;
-      this.downloadCoordinator = downloadCoordinator;
-    }
-    public void run() {
-      long started = System.currentTimeMillis(), sleep;
-      while ((sleep = started + this.timeoutMillis
-          - System.currentTimeMillis()) > 0L) {
-        try {
-          Thread.sleep(sleep);
-        } catch (InterruptedException e) {
-          return;
-        }
-      }
-      this.downloadCoordinator.interruptAllDownloads();
-    }
-  }
-
-  /* Are we missing the consensus, and should the next directory that
-   * hasn't tried downloading it before attempt to download it? */
-  private boolean missingConsensus = false;
-
-  /* Which directories are currently attempting to download the
-   * consensus? */
-  private Set<String> requestingConsensuses = new HashSet<>();
-
-  /* Which directories have attempted to download the consensus so far,
-   * including those directories that are currently attempting it? */
-  private Set<String> requestedConsensuses = new HashSet<>();
-
-  /* Which votes are we currently missing? */
-  private Set<String> missingVotes = new HashSet<>();
-
-  /* Which vote (map value) is a given directory (map key) currently
-   * attempting to download? */
-  private Map<String, String> requestingVotes = new HashMap<>();
-
-  /* Which votes (map value) has a given directory (map key) attempted or
-   * is currently attempting to download? */
-  private Map<String, Set<String>> requestedVotes = new HashMap<>();
-
-  private boolean hasFinishedDownloading = false;
-
-  /* Look up what request a directory should make next.  If there is
-   * nothing to do right now, but maybe later, block the caller.  If
-   * we're done downloading, return null to notify the caller. */
-  @Override
-  public synchronized DescriptorRequestImpl createRequest(
-      String nickname) {
-    while (!this.hasFinishedDownloading) {
-      DescriptorRequestImpl request = new DescriptorRequestImpl();
-      request.setDirectoryNickname(nickname);
-      if ((this.missingConsensus ||
-          (this.downloadConsensusFromAllAuthorities &&
-          this.directoryAuthorities.containsKey(nickname))) &&
-          !this.requestedConsensuses.contains(nickname)) {
-        if (!this.downloadConsensusFromAllAuthorities) {
-          this.missingConsensus = false;
-        }
-        this.requestingConsensuses.add(nickname);
-        this.requestedConsensuses.add(nickname);
-        request.setRequestedResource(
-            "/tor/status-vote/current/consensus.z");
-        request.setDescriptorType("consensus");
-        return request;
-      }
-      if (!this.missingVotes.isEmpty() &&
-          this.directoryAuthorities.containsKey(nickname)) {
-        String requestingVote = null;
-        for (String missingVote : this.missingVotes) {
-          if (!this.requestedVotes.containsKey(nickname) ||
-              !this.requestedVotes.get(nickname).contains(missingVote)) {
-            requestingVote = missingVote;
-          }
-        }
-        if (requestingVote != null) {
-          this.requestingVotes.put(nickname, requestingVote);
-          if (!this.requestedVotes.containsKey(nickname)) {
-            this.requestedVotes.put(nickname, new HashSet<String>());
-          }
-          this.requestedVotes.get(nickname).add(requestingVote);
-          this.missingVotes.remove(requestingVote);
-          request.setRequestedResource("/tor/status-vote/current/"
-              + requestingVote + ".z");
-          request.setDescriptorType("vote");
-          return request;
-        }
-      }
-      /* TODO Add server descriptors and extra-info descriptors later. */
-      try {
-        this.wait();
-      } catch (InterruptedException e) {
-        /* TODO What shall we do? */
-      }
-    }
-    return null;
-  }
-
-  /* Deliver a response which may either contain one or more descriptors
-   * or a failure response code.  Update the lists of missing descriptors,
-   * decide if there are more descriptors to download, and wake up any
-   * waiting downloader threads. */
-  @Override
-  public synchronized void deliverResponse(
-      DescriptorRequestImpl response) {
-    String nickname = response.getDirectoryNickname();
-    if (response.getException() != null) {
-      this.runningDirectories.remove(nickname);
-    }
-    switch (response.getDescriptorType()) {
-      case "consensus":
-        this.requestingConsensuses.remove(nickname);
-        if (response.getResponseCode() == 200 &&
-            response.getDescriptors() != null) {
-          if (this.includeCurrentReferencedVotes) {
-            /* TODO Only add votes if the consensus is not older than one
-             * hour.  Or does that make no sense? */
-            for (Descriptor parsedDescriptor :
-                response.getDescriptors()) {
-              if (!(parsedDescriptor instanceof
-                  RelayNetworkStatusConsensus)) {
-                continue;
-              }
-              RelayNetworkStatusConsensus parsedConsensus =
-                  (RelayNetworkStatusConsensus) parsedDescriptor;
-              for (DirSourceEntry dirSource :
-                  parsedConsensus.getDirSourceEntries().values()) {
-                String identity = dirSource.getIdentity();
-                if (!this.missingVotes.contains(identity)) {
-                  boolean alreadyRequested = false;
-                  for (Set<String> requestedBefore :
-                      this.requestedVotes.values()) {
-                    if (requestedBefore.contains(identity)) {
-                      alreadyRequested = true;
-                      break;
-                    }
-                  }
-                  if (!alreadyRequested) {
-                    this.missingVotes.add(identity);
-                  }
-                }
-              }
-            }
-            /* TODO Later, add referenced server descriptors. */
-          }
-        } else {
-          this.missingConsensus = true;
-        }
-        break;
-      case "vote":
-        String requestedVote = requestingVotes.remove(nickname);
-        if (response.getResponseCode() != 200) {
-          this.missingVotes.add(requestedVote);
-        }
-    }
-    if (response.getRequestEnd() != 0L) {
-      this.descriptorQueue.add(response);
-    }
-    boolean doneDownloading = true;
-    if ((this.missingConsensus ||
-        this.downloadConsensusFromAllAuthorities) &&
-        (!this.requestedConsensuses.containsAll(
-        this.runningDirectories) ||
-        !this.requestingConsensuses.isEmpty())) {
-      doneDownloading = false;
-    }
-    if (!this.requestingVotes.isEmpty()) {
-      doneDownloading = false;
-    } else if (!this.missingVotes.isEmpty()) {
-      if (!this.requestedVotes.keySet().containsAll(
-          this.runningDirectories)) {
-        doneDownloading = false;
-      } else {
-        for (String missingVote : this.missingVotes) {
-          for (String runningDirectory : this.runningDirectories) {
-            Set<String> reqVotes = this.requestedVotes.get(
-                runningDirectory);
-            if (!reqVotes.contains(missingVote)) {
-              doneDownloading = false;
-            }
-          }
-        }
-      }
-    }
-    if (doneDownloading) {
-      this.hasFinishedDownloading = true;
-      this.globalTimerThread.interrupt();
-      this.descriptorQueue.setOutOfDescriptors();
-    }
-    /* Wake up all waiting downloader threads.  Maybe they can now
-     * download something, or they'll realize we're done downloading. */
-    this.notifyAll();
-  }
-
-  private synchronized void interruptAllDownloads() {
-    this.hasFinishedDownloading = true;
-    this.notifyAll();
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/ExitListEntryImpl.java b/src/org/torproject/descriptor/impl/ExitListEntryImpl.java
deleted file mode 100644
index efbf31c..0000000
--- a/src/org/torproject/descriptor/impl/ExitListEntryImpl.java
+++ /dev/null
@@ -1,216 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ExitList;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.ExitListEntry;
-
-public class ExitListEntryImpl implements ExitListEntry, ExitList.Entry {
-
-  private byte[] exitListEntryBytes;
-
-  private boolean failUnrecognizedDescriptorLines;
-  private List<String> unrecognizedLines;
-  protected List<String> getAndClearUnrecognizedLines() {
-    List<String> lines = this.unrecognizedLines;
-    this.unrecognizedLines = null;
-    return lines;
-  }
-
-  @Deprecated
-  private ExitListEntryImpl(String fingerprint, long publishedMillis,
-      long lastStatusMillis, String exitAddress, long scanMillis) {
-    this.fingerprint = fingerprint;
-    this.publishedMillis = publishedMillis;
-    this.lastStatusMillis = lastStatusMillis;
-    this.exitAddresses.put(exitAddress, scanMillis);
-  }
-
-  @Deprecated
-  List<ExitListEntry> oldEntries() {
-    List<ExitListEntry> result = new ArrayList<>();
-    if (this.exitAddresses.size() > 1) {
-      for (Map.Entry<String, Long> entry :
-          this.exitAddresses.entrySet()) {
-        result.add(new ExitListEntryImpl(this.fingerprint,
-            this.publishedMillis, this.lastStatusMillis, entry.getKey(),
-            entry.getValue()));
-      }
-    } else {
-      result.add(this);
-    }
-    return result;
-  }
-
-  protected ExitListEntryImpl(byte[] exitListEntryBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    this.exitListEntryBytes = exitListEntryBytes;
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-    this.initializeKeywords();
-    this.parseExitListEntryBytes();
-    this.checkAndClearKeywords();
-  }
-
-  private SortedSet<String> keywordCountingSet;
-  private void initializeKeywords() {
-    this.keywordCountingSet = new TreeSet<>();
-    this.keywordCountingSet.add("ExitNode");
-    this.keywordCountingSet.add("Published");
-    this.keywordCountingSet.add("LastStatus");
-    this.keywordCountingSet.add("ExitAddress");
-  }
-
-  private void parsedExactlyOnceKeyword(String keyword)
-      throws DescriptorParseException {
-    if (!this.keywordCountingSet.contains(keyword)) {
-      throw new DescriptorParseException("Duplicate '" + keyword
-          + "' line in exit list entry.");
-    }
-    this.keywordCountingSet.remove(keyword);
-  }
-
-  private void checkAndClearKeywords() throws DescriptorParseException {
-    for (String missingKeyword : this.keywordCountingSet) {
-      throw new DescriptorParseException("Missing '" + missingKeyword
-          + "' line in exit list entry.");
-    }
-    this.keywordCountingSet = null;
-  }
-
-  private void parseExitListEntryBytes()
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.exitListEntryBytes)).
-        useDelimiter(ExitList.EOL);
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split(" ");
-      String keyword = parts[0];
-      switch (keyword) {
-        case "ExitNode":
-          this.parseExitNodeLine(line, parts);
-          break;
-        case "Published":
-          this.parsePublishedLine(line, parts);
-          break;
-        case "LastStatus":
-          this.parseLastStatusLine(line, parts);
-          break;
-        case "ExitAddress":
-          this.parseExitAddressLine(line, parts);
-          break;
-        default:
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in exit list entry.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-      }
-    }
-  }
-
-  private void parseExitNodeLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Invalid line '" + line + "' in "
-          + "exit list entry.");
-    }
-    this.parsedExactlyOnceKeyword(parts[0]);
-    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
-        parts[1]);
-  }
-
-  private void parsePublishedLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 3) {
-      throw new DescriptorParseException("Invalid line '" + line + "' in "
-          + "exit list entry.");
-    }
-    this.parsedExactlyOnceKeyword(parts[0]);
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseLastStatusLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 3) {
-      throw new DescriptorParseException("Invalid line '" + line + "' in "
-          + "exit list entry.");
-    }
-    this.parsedExactlyOnceKeyword(parts[0]);
-    this.lastStatusMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseExitAddressLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 4) {
-      throw new DescriptorParseException("Invalid line '" + line + "' in "
-          + "exit list entry.");
-    }
-    this.keywordCountingSet.remove(parts[0]);
-    this.exitAddresses.put(ParseHelper.parseIpv4Address(line, parts[1]),
-        ParseHelper.parseTimestampAtIndex(line, parts, 2, 3));
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private long lastStatusMillis;
-  @Override
-  public long getLastStatusMillis() {
-    return this.lastStatusMillis;
-  }
-
-  private String exitAddress;
-  @Override
-  public String getExitAddress() {
-    if (null == exitAddress) {
-      Map.Entry<String, Long> randomEntry =
-          this.exitAddresses.entrySet().iterator().next();
-      this.exitAddress = randomEntry.getKey();
-      this.scanMillis = randomEntry.getValue();
-    }
-    return this.exitAddress;
-  }
-
-  private Map<String, Long> exitAddresses = new HashMap<>();
-  @Override
-  public Map<String, Long> getExitAddresses(){
-    return new HashMap<>(this.exitAddresses);
-  }
-
-  private long scanMillis;
-  @Override
-  public long getScanMillis() {
-    if (null == exitAddress) {
-      getExitAddress();
-    }
-    return scanMillis;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/ExitListImpl.java b/src/org/torproject/descriptor/impl/ExitListImpl.java
deleted file mode 100644
index 10619ba..0000000
--- a/src/org/torproject/descriptor/impl/ExitListImpl.java
+++ /dev/null
@@ -1,142 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.TimeZone;
-
-import org.torproject.descriptor.ExitList;
-import org.torproject.descriptor.ExitListEntry;
-
-public class ExitListImpl extends DescriptorImpl implements ExitList {
-
-  protected ExitListImpl(byte[] rawDescriptorBytes, String fileName,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(rawDescriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.splitAndParseExitListEntries(rawDescriptorBytes);
-    this.setPublishedMillisFromFileName(fileName);
-  }
-
-  private void setPublishedMillisFromFileName(String fileName)
-      throws DescriptorParseException {
-    if (this.downloadedMillis == 0L &&
-        fileName.length() == "2012-02-01-04-06-24".length()) {
-      try {
-        SimpleDateFormat fileNameFormat = new SimpleDateFormat(
-            "yyyy-MM-dd-HH-mm-ss");
-        fileNameFormat.setLenient(false);
-        fileNameFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-        this.downloadedMillis = fileNameFormat.parse(fileName).getTime();
-      } catch (ParseException e) {
-        /* Handle below. */
-      }
-    }
-    if (this.downloadedMillis == 0L) {
-      throw new DescriptorParseException("Unrecognized exit list file "
-          + "name '" + fileName + "'.");
-    }
-  }
-
-  private void splitAndParseExitListEntries(byte[] rawDescriptorBytes)
-      throws DescriptorParseException {
-    if (this.rawDescriptorBytes.length == 0) {
-      throw new DescriptorParseException("Descriptor is empty.");
-    }
-    String descriptorString = new String(rawDescriptorBytes);
-    Scanner s = new Scanner(descriptorString).useDelimiter(EOL);
-    StringBuilder sb = new StringBuilder();
-    boolean firstEntry = true;
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.startsWith("@")) { /* Skip annotation. */
-        if (!s.hasNext()) {
-          throw new DescriptorParseException("Descriptor is empty.");
-        } else {
-          line = s.next();
-        }
-      }
-      String[] parts = line.split(" ");
-      String keyword = parts[0];
-      switch (keyword) {
-        case "Downloaded":
-          this.downloadedMillis = ParseHelper.parseTimestampAtIndex(line,
-              parts, 1, 2);
-          break;
-        case "ExitNode":
-          if (!firstEntry) {
-            this.parseExitListEntry(sb.toString().getBytes());
-          } else {
-            firstEntry = false;
-          }
-          sb = new StringBuilder();
-          sb.append(line).append(ExitList.EOL);
-          break;
-        case "Published":
-          sb.append(line).append(ExitList.EOL);
-          break;
-        case "LastStatus":
-          sb.append(line).append(ExitList.EOL);
-          break;
-        case "ExitAddress":
-          sb.append(line).append(ExitList.EOL);
-          break;
-        default:
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in exit list.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-      }
-    }
-    /* Parse the last entry. */
-    this.parseExitListEntry(sb.toString().getBytes());
-  }
-
-  protected void parseExitListEntry(byte[] exitListEntryBytes)
-      throws DescriptorParseException {
-    ExitListEntryImpl exitListEntry = new ExitListEntryImpl(
-        exitListEntryBytes, this.failUnrecognizedDescriptorLines);
-    this.exitListEntries.add(exitListEntry);
-    this.oldExitListEntries.addAll(exitListEntry.oldEntries());
-    List<String> unrecognizedExitListEntryLines = exitListEntry.
-        getAndClearUnrecognizedLines();
-    if (unrecognizedExitListEntryLines != null) {
-      if (this.unrecognizedLines == null) {
-        this.unrecognizedLines = new ArrayList<>();
-      }
-      this.unrecognizedLines.addAll(unrecognizedExitListEntryLines);
-    }
-  }
-
-  private long downloadedMillis;
-  @Override
-  public long getDownloadedMillis() {
-    return this.downloadedMillis;
-  }
-
-  private Set<ExitListEntry> oldExitListEntries = new HashSet<>();
-  @Deprecated
-  @Override
-  public Set<ExitListEntry> getExitListEntries() {
-    return new HashSet<>(this.oldExitListEntries);
-  }
-
-  private Set<ExitList.Entry> exitListEntries = new HashSet<>();
-  @Override
-  public Set<ExitList.Entry> getEntries() {
-    return new HashSet<>(this.exitListEntries);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/ExtraInfoDescriptorImpl.java b/src/org/torproject/descriptor/impl/ExtraInfoDescriptorImpl.java
deleted file mode 100644
index 3f72616..0000000
--- a/src/org/torproject/descriptor/impl/ExtraInfoDescriptorImpl.java
+++ /dev/null
@@ -1,1284 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.BandwidthHistory;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-
-public abstract class ExtraInfoDescriptorImpl extends DescriptorImpl
-    implements ExtraInfoDescriptor {
-
-  protected ExtraInfoDescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.parseDescriptorBytes();
-    this.calculateDigest();
-    this.calculateDigestSha256();
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
-        "extra-info,published").split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> dirreqStatsKeywords = new HashSet<>(Arrays.asList((
-        "dirreq-stats-end,dirreq-v2-ips,dirreq-v3-ips,dirreq-v2-reqs,"
-        + "dirreq-v3-reqs,dirreq-v2-share,dirreq-v3-share,dirreq-v2-resp,"
-        + "dirreq-v3-resp,dirreq-v2-direct-dl,dirreq-v3-direct-dl,"
-        + "dirreq-v2-tunneled-dl,dirreq-v3-tunneled-dl,").split(",")));
-    Set<String> entryStatsKeywords = new HashSet<>(Arrays.asList(
-        "entry-stats-end,entry-ips".split(",")));
-    Set<String> cellStatsKeywords = new HashSet<>(Arrays.asList((
-        "cell-stats-end,cell-processed-cells,cell-queued-cells,"
-        + "cell-time-in-queue,cell-circuits-per-decile").split(",")));
-    Set<String> connBiDirectStatsKeywords = new HashSet<>(
-        Arrays.asList("conn-bi-direct".split(",")));
-    Set<String> exitStatsKeywords = new HashSet<>(Arrays.asList((
-        "exit-stats-end,exit-kibibytes-written,exit-kibibytes-read,"
-        + "exit-streams-opened").split(",")));
-    Set<String> bridgeStatsKeywords = new HashSet<>(Arrays.asList(
-        "bridge-stats-end,bridge-stats-ips".split(",")));
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
-        "identity-ed25519,master-key-ed25519,read-history,write-history,"
-        + "dirreq-read-history,dirreq-write-history,geoip-db-digest,"
-        + "router-sig-ed25519,router-signature,router-digest-sha256,"
-        + "router-digest").split(",")));
-    atMostOnceKeywords.addAll(dirreqStatsKeywords);
-    atMostOnceKeywords.addAll(entryStatsKeywords);
-    atMostOnceKeywords.addAll(cellStatsKeywords);
-    atMostOnceKeywords.addAll(connBiDirectStatsKeywords);
-    atMostOnceKeywords.addAll(exitStatsKeywords);
-    atMostOnceKeywords.addAll(bridgeStatsKeywords);
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkKeywordsDependOn(dirreqStatsKeywords, "dirreq-stats-end");
-    this.checkKeywordsDependOn(entryStatsKeywords, "entry-stats-end");
-    this.checkKeywordsDependOn(cellStatsKeywords, "cell-stats-end");
-    this.checkKeywordsDependOn(exitStatsKeywords, "exit-stats-end");
-    this.checkKeywordsDependOn(bridgeStatsKeywords, "bridge-stats-end");
-    this.checkFirstKeyword("extra-info");
-    this.clearParsedKeywords();
-    return;
-  }
-
-  private void parseDescriptorBytes() throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
-        useDelimiter("\n");
-    String nextCrypto = "";
-    List<String> cryptoLines = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      String lineNoOpt = line.startsWith("opt ") ?
-          line.substring("opt ".length()) : line;
-      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
-      String keyword = partsNoOpt[0];
-      switch (keyword) {
-      case "extra-info":
-        this.parseExtraInfoLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "published":
-        this.parsePublishedLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "read-history":
-        this.parseReadHistoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "write-history":
-        this.parseWriteHistoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "geoip-db-digest":
-        this.parseGeoipDbDigestLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "geoip6-db-digest":
-        this.parseGeoip6DbDigestLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "geoip-start-time":
-        this.parseGeoipStartTimeLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "geoip-client-origins":
-        this.parseGeoipClientOriginsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-stats-end":
-        this.parseDirreqStatsEndLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v2-ips":
-        this.parseDirreqV2IpsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v3-ips":
-        this.parseDirreqV3IpsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v2-reqs":
-        this.parseDirreqV2ReqsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v3-reqs":
-        this.parseDirreqV3ReqsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v2-share":
-        this.parseDirreqV2ShareLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v3-share":
-        this.parseDirreqV3ShareLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v2-resp":
-        this.parseDirreqV2RespLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v3-resp":
-        this.parseDirreqV3RespLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v2-direct-dl":
-        this.parseDirreqV2DirectDlLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v3-direct-dl":
-        this.parseDirreqV3DirectDlLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v2-tunneled-dl":
-        this.parseDirreqV2TunneledDlLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-v3-tunneled-dl":
-        this.parseDirreqV3TunneledDlLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-read-history":
-        this.parseDirreqReadHistoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dirreq-write-history":
-        this.parseDirreqWriteHistoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "entry-stats-end":
-        this.parseEntryStatsEndLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "entry-ips":
-        this.parseEntryIpsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "cell-stats-end":
-        this.parseCellStatsEndLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "cell-processed-cells":
-        this.parseCellProcessedCellsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "cell-queued-cells":
-        this.parseCellQueuedCellsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "cell-time-in-queue":
-        this.parseCellTimeInQueueLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "cell-circuits-per-decile":
-        this.parseCellCircuitsPerDecileLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "conn-bi-direct":
-        this.parseConnBiDirectLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "exit-stats-end":
-        this.parseExitStatsEndLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "exit-kibibytes-written":
-        this.parseExitKibibytesWrittenLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "exit-kibibytes-read":
-        this.parseExitKibibytesReadLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "exit-streams-opened":
-        this.parseExitStreamsOpenedLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "bridge-stats-end":
-        this.parseBridgeStatsEndLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "bridge-ips":
-        this.parseBridgeStatsIpsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "bridge-ip-versions":
-        this.parseBridgeIpVersionsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "bridge-ip-transports":
-        this.parseBridgeIpTransportsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "transport":
-        this.parseTransportLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "hidserv-stats-end":
-        this.parseHidservStatsEndLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "hidserv-rend-relayed-cells":
-        this.parseHidservRendRelayedCellsLine(line, lineNoOpt,
-            partsNoOpt);
-        break;
-      case "hidserv-dir-onions-seen":
-        this.parseHidservDirOnionsSeenLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "identity-ed25519":
-        this.parseIdentityEd25519Line(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "identity-ed25519";
-        break;
-      case "master-key-ed25519":
-        this.parseMasterKeyEd25519Line(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-sig-ed25519":
-        this.parseRouterSigEd25519Line(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-signature":
-        this.parseRouterSignatureLine(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "router-signature";
-        break;
-      case "router-digest":
-        this.parseRouterDigestLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-digest-sha256":
-        this.parseRouterDigestSha256Line(line, lineNoOpt, partsNoOpt);
-        break;
-      case "-----BEGIN":
-        cryptoLines = new ArrayList<>();
-        cryptoLines.add(line);
-        break;
-      case "-----END":
-        cryptoLines.add(line);
-        StringBuilder sb = new StringBuilder();
-        for (String cryptoLine : cryptoLines) {
-          sb.append("\n").append(cryptoLine);
-        }
-        String cryptoString = sb.toString().substring(1);
-        switch (nextCrypto) {
-        case "router-signature":
-          this.routerSignature = cryptoString;
-          break;
-        case "identity-ed25519":
-          this.identityEd25519 = cryptoString;
-          this.parseIdentityEd25519CryptoBlock(cryptoString);
-          break;
-        default:
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized crypto "
-                + "block '" + cryptoString + "' in extra-info "
-                + "descriptor.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.addAll(cryptoLines);
-          }
-          cryptoLines = null;
-          nextCrypto = "";
-        }
-        break;
-      default:
-        if (cryptoLines != null) {
-          cryptoLines.add(line);
-        } else {
-          ParseHelper.parseKeyword(line, partsNoOpt[0]);
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in extra-info descriptor.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  private void parseExtraInfoLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 3) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in extra-info descriptor.");
-    }
-    this.nickname = ParseHelper.parseNickname(line, partsNoOpt[1]);
-    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
-        partsNoOpt[2]);
-  }
-
-  private void parsePublishedLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseReadHistoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.readHistory = new BandwidthHistoryImpl(line, lineNoOpt,
-        partsNoOpt);
-  }
-
-  private void parseWriteHistoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.writeHistory = new BandwidthHistoryImpl(line, lineNoOpt,
-        partsNoOpt);
-  }
-
-  private void parseGeoipDbDigestLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in extra-info descriptor.");
-    }
-    this.geoipDbDigest = ParseHelper.parseTwentyByteHexString(line,
-        partsNoOpt[1]);
-  }
-
-  private void parseGeoip6DbDigestLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in extra-info descriptor.");
-    }
-    this.geoip6DbDigest = ParseHelper.parseTwentyByteHexString(line,
-        partsNoOpt[1]);
-  }
-
-  private void parseGeoipStartTimeLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 3) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in extra-info descriptor.");
-    }
-    this.geoipStartTimeMillis = ParseHelper.parseTimestampAtIndex(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseGeoipClientOriginsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.geoipClientOrigins =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseDirreqStatsEndLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        5);
-    this.dirreqStatsEndMillis = parsedStatsEndData[0];
-    this.dirreqStatsIntervalLength = parsedStatsEndData[1];
-  }
-
-  private long[] parseStatsEndLine(String line, String partsNoOpt[],
-      int partsNoOptExpectedLength) throws DescriptorParseException {
-    if (partsNoOpt.length != partsNoOptExpectedLength ||
-        partsNoOpt[3].length() < 2 || !partsNoOpt[3].startsWith("(") ||
-        !partsNoOpt[4].equals("s)")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    long[] result = new long[2];
-    result[0] = ParseHelper.parseTimestampAtIndex(line, partsNoOpt, 1, 2);
-    result[1] = ParseHelper.parseSeconds(line,
-        partsNoOpt[3].substring(1));
-    if (result[1] <= 0) {
-      throw new DescriptorParseException("Interval length must be "
-          + "positive in line '" + line + "'.");
-    }
-    return result;
-  }
-
-  private void parseDirreqV2IpsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV2Ips = ParseHelper.parseCommaSeparatedKeyIntegerValueList(
-        line, partsNoOpt, 1, 2);
-  }
-
-  private void parseDirreqV3IpsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV3Ips = ParseHelper.parseCommaSeparatedKeyIntegerValueList(
-        line, partsNoOpt, 1, 2);
-  }
-
-  private void parseDirreqV2ReqsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV2Reqs =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseDirreqV3ReqsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV3Reqs =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseDirreqV2ShareLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV2Share = this.parseShareLine(line, partsNoOpt);
-  }
-
-  private void parseDirreqV3ShareLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV3Share = this.parseShareLine(line, partsNoOpt);
-  }
-
-  private double parseShareLine(String line, String[] partsNoOpt)
-      throws DescriptorParseException {
-    double share = -1.0;
-    if (partsNoOpt.length == 2 && partsNoOpt[1].length() >= 2 &&
-        partsNoOpt[1].endsWith("%")) {
-      String shareString = partsNoOpt[1];
-      shareString = shareString.substring(0, shareString.length() - 1);
-      try {
-        share = Double.parseDouble(shareString);
-      } catch (NumberFormatException e) {
-        /* Handle below. */
-      }
-    }
-    if (share < 0.0) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    return share;
-  }
-
-  private void parseDirreqV2RespLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV2Resp =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 0);
-  }
-
-  private void parseDirreqV3RespLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV3Resp =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 0);
-  }
-
-  private void parseDirreqV2DirectDlLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV2DirectDl =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 0);
-  }
-
-  private void parseDirreqV3DirectDlLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV3DirectDl =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 0);
-  }
-
-  private void parseDirreqV2TunneledDlLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV2TunneledDl =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 0);
-  }
-
-  private void parseDirreqV3TunneledDlLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqV3TunneledDl =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(
-        line,partsNoOpt, 1, 0);
-  }
-
-  private void parseDirreqReadHistoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqReadHistory = new BandwidthHistoryImpl(line, lineNoOpt,
-        partsNoOpt);
-  }
-
-  private void parseDirreqWriteHistoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.dirreqWriteHistory = new BandwidthHistoryImpl(line, lineNoOpt,
-        partsNoOpt);
-  }
-
-  private void parseEntryStatsEndLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        5);
-    this.entryStatsEndMillis = parsedStatsEndData[0];
-    this.entryStatsIntervalLength = parsedStatsEndData[1];
-  }
-
-  private void parseEntryIpsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.entryIps = ParseHelper.parseCommaSeparatedKeyIntegerValueList(
-        line, partsNoOpt, 1, 2);
-  }
-
-  private void parseCellStatsEndLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        5);
-    this.cellStatsEndMillis = parsedStatsEndData[0];
-    this.cellStatsIntervalLength = parsedStatsEndData[1];
-  }
-
-  private void parseCellProcessedCellsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.cellProcessedCells = ParseHelper.
-        parseCommaSeparatedIntegerValueList(line, partsNoOpt, 1);
-    if (this.cellProcessedCells.length != 10) {
-      throw new DescriptorParseException("There must be exact ten values "
-          + "in line '" + line + "'.");
-    }
-  }
-
-  private void parseCellQueuedCellsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.cellQueuedCells = ParseHelper.parseCommaSeparatedDoubleValueList(
-        line, partsNoOpt, 1);
-    if (this.cellQueuedCells.length != 10) {
-      throw new DescriptorParseException("There must be exact ten values "
-          + "in line '" + line + "'.");
-    }
-  }
-
-  private void parseCellTimeInQueueLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.cellTimeInQueue = ParseHelper.
-        parseCommaSeparatedIntegerValueList(line, partsNoOpt, 1);
-    if (this.cellTimeInQueue.length != 10) {
-      throw new DescriptorParseException("There must be exact ten values "
-          + "in line '" + line + "'.");
-    }
-  }
-
-  private void parseCellCircuitsPerDecileLine(String line,
-      String lineNoOpt, String[] partsNoOpt)
-      throws DescriptorParseException {
-    int circuits = -1;
-    if (partsNoOpt.length == 2) {
-      try {
-        circuits = Integer.parseInt(partsNoOpt[1]);
-      } catch (NumberFormatException e) {
-        /* Handle below. */
-      }
-    }
-    if (circuits < 0) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.cellCircuitsPerDecile = circuits;
-  }
-
-  private void parseConnBiDirectLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        6);
-    this.connBiDirectStatsEndMillis = parsedStatsEndData[0];
-    this.connBiDirectStatsIntervalLength = parsedStatsEndData[1];
-    Integer[] parsedConnBiDirectStats = ParseHelper.
-        parseCommaSeparatedIntegerValueList(line, partsNoOpt, 5);
-    if (parsedConnBiDirectStats.length != 4) {
-      throw new DescriptorParseException("Illegal line '" + line + "' in "
-          + "extra-info descriptor.");
-    }
-    this.connBiDirectBelow = parsedConnBiDirectStats[0];
-    this.connBiDirectRead = parsedConnBiDirectStats[1];
-    this.connBiDirectWrite = parsedConnBiDirectStats[2];
-    this.connBiDirectBoth = parsedConnBiDirectStats[3];
-  }
-
-  private void parseExitStatsEndLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        5);
-    this.exitStatsEndMillis = parsedStatsEndData[0];
-    this.exitStatsIntervalLength = parsedStatsEndData[1];
-  }
-
-  private void parseExitKibibytesWrittenLine(String line,
-      String lineNoOpt, String[] partsNoOpt)
-      throws DescriptorParseException {
-    this.exitKibibytesWritten = this.sortByPorts(ParseHelper.
-        parseCommaSeparatedKeyLongValueList(line, partsNoOpt, 1, 0));
-    this.verifyPorts(line, this.exitKibibytesWritten.keySet());
-    this.verifyBytesOrStreams(line, this.exitKibibytesWritten.values());
-  }
-
-  private void parseExitKibibytesReadLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.exitKibibytesRead = this.sortByPorts(ParseHelper.
-        parseCommaSeparatedKeyLongValueList(line, partsNoOpt, 1, 0));
-    this.verifyPorts(line, this.exitKibibytesRead.keySet());
-    this.verifyBytesOrStreams(line, this.exitKibibytesRead.values());
-  }
-
-  private void parseExitStreamsOpenedLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.exitStreamsOpened = this.sortByPorts(ParseHelper.
-        parseCommaSeparatedKeyLongValueList(line, partsNoOpt, 1, 0));
-    this.verifyPorts(line, this.exitStreamsOpened.keySet());
-    this.verifyBytesOrStreams(line, this.exitStreamsOpened.values());
-  }
-
-  private SortedMap<String, Long> sortByPorts(
-      SortedMap<String, Long> naturalOrder) {
-    SortedMap<String, Long> byPortNumber =
-        new TreeMap<String, Long>(new Comparator<String>() {
-          public int compare(String arg0, String arg1) {
-            int port0 = 0, port1 = 0;
-            try {
-              port1 = Integer.parseInt(arg1);
-            } catch (NumberFormatException e) {
-              return -1;
-            }
-            try {
-              port0 = Integer.parseInt(arg0);
-            } catch (NumberFormatException e) {
-              return 1;
-            }
-            if (port0 < port1) {
-              return -1;
-            } else if (port0 > port1) {
-              return 1;
-            } else {
-              return 0;
-            }
-          }});
-    byPortNumber.putAll(naturalOrder);
-    return byPortNumber;
-  }
-
-  private void verifyPorts(String line, Set<String> ports)
-      throws DescriptorParseException {
-    boolean valid = true;
-    try {
-      for (String port : ports) {
-        if (!port.equals("other") && Integer.parseInt(port) <= 0) {
-          valid = false;
-          break;
-        }
-      }
-    } catch (NumberFormatException e) {
-      valid = false;
-    }
-    if (!valid) {
-      throw new DescriptorParseException("Invalid port in line '" + line
-          + "'.");
-    }
-  }
-
-  private void verifyBytesOrStreams(String line,
-      Collection<Long> bytesOrStreams) throws DescriptorParseException {
-    boolean valid = true;
-    for (long s : bytesOrStreams) {
-      if (s < 0L) {
-        valid = false;
-        break;
-      }
-    }
-    if (!valid) {
-      throw new DescriptorParseException("Invalid value in line '" + line
-          + "'.");
-    }
-  }
-
-  private void parseBridgeStatsEndLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        5);
-    this.bridgeStatsEndMillis = parsedStatsEndData[0];
-    this.bridgeStatsIntervalLength = parsedStatsEndData[1];
-  }
-
-  private void parseBridgeStatsIpsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.bridgeIps =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseBridgeIpVersionsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.bridgeIpVersions =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseBridgeIpTransportsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.bridgeIpTransports =
-        ParseHelper.parseCommaSeparatedKeyIntegerValueList(line,
-        partsNoOpt, 1, 0);
-  }
-
-  private void parseTransportLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.transports.add(partsNoOpt[1]);
-  }
-
-  private void parseHidservStatsEndLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    long[] parsedStatsEndData = this.parseStatsEndLine(line, partsNoOpt,
-        5);
-    this.hidservStatsEndMillis = parsedStatsEndData[0];
-    this.hidservStatsIntervalLength = parsedStatsEndData[1];
-  }
-
-  private void parseHidservRendRelayedCellsLine(String line,
-      String lineNoOpt, String[] partsNoOpt)
-      throws DescriptorParseException {
-    if (partsNoOpt.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    try {
-      this.hidservRendRelayedCells = Double.parseDouble(partsNoOpt[1]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.hidservRendRelayedCellsParameters =
-        ParseHelper.parseSpaceSeparatedStringKeyDoubleValueMap(line,
-        partsNoOpt, 2);
-  }
-
-  private void parseHidservDirOnionsSeenLine(String line,
-      String lineNoOpt, String[] partsNoOpt)
-      throws DescriptorParseException {
-    if (partsNoOpt.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    try {
-      this.hidservDirOnionsSeen = Double.parseDouble(partsNoOpt[1]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.hidservDirOnionsSeenParameters =
-        ParseHelper.parseSpaceSeparatedStringKeyDoubleValueMap(line,
-        partsNoOpt, 2);
-  }
-
-  private void parseRouterSignatureLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("router-signature")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseRouterDigestLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.extraInfoDigest = ParseHelper.parseTwentyByteHexString(line,
-        partsNoOpt[1]);
-  }
-
-  private void parseIdentityEd25519Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 1) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseIdentityEd25519CryptoBlock(String cryptoString)
-      throws DescriptorParseException {
-    String masterKeyEd25519FromIdentityEd25519 =
-        ParseHelper.parseMasterKeyEd25519FromIdentityEd25519CryptoBlock(
-        cryptoString);
-    if (this.masterKeyEd25519 != null && !this.masterKeyEd25519.equals(
-        masterKeyEd25519FromIdentityEd25519)) {
-      throw new DescriptorParseException("Mismatch between "
-          + "identity-ed25519 and master-key-ed25519.");
-    }
-    this.masterKeyEd25519 = masterKeyEd25519FromIdentityEd25519;
-  }
-
-  private void parseMasterKeyEd25519Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    String masterKeyEd25519FromMasterKeyEd25519Line = partsNoOpt[1];
-    if (this.masterKeyEd25519 != null && !masterKeyEd25519.equals(
-        masterKeyEd25519FromMasterKeyEd25519Line)) {
-      throw new DescriptorParseException("Mismatch between "
-          + "identity-ed25519 and master-key-ed25519.");
-    }
-    this.masterKeyEd25519 = masterKeyEd25519FromMasterKeyEd25519Line;
-  }
-
-  private void parseRouterSigEd25519Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.routerSignatureEd25519 = partsNoOpt[1];
-  }
-
-  private void parseRouterDigestSha256Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    ParseHelper.parseThirtyTwoByteBase64String(line, partsNoOpt[1]);
-    this.extraInfoDigestSha256 = partsNoOpt[1];
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    if (this.extraInfoDigest != null) {
-      /* We already learned the descriptor digest of this bridge
-       * descriptor from a "router-digest" line. */
-      return;
-    }
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "extra-info ";
-      String sigToken = "\nrouter-signature\n";
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, sig - start);
-        this.extraInfoDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-1").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.extraInfoDigest == null) {
-      throw new DescriptorParseException("Could not calculate extra-info "
-          + "descriptor digest.");
-    }
-  }
-
-  private void calculateDigestSha256() throws DescriptorParseException {
-    if (this.extraInfoDigestSha256 != null) {
-      /* We already learned the descriptor digest of this bridge
-       * descriptor from a "router-digest-sha256" line. */
-      return;
-    }
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "extra-info ";
-      String sigToken = "\n-----END SIGNATURE-----\n";
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start, forDigest,
-            0, sig - start);
-        this.extraInfoDigestSha256 = DatatypeConverter.printBase64Binary(
-            MessageDigest.getInstance("SHA-256").digest(forDigest)).
-            replaceAll("=", "");
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.extraInfoDigestSha256 == null) {
-      throw new DescriptorParseException("Could not calculate extra-info "
-          + "descriptor SHA-256 digest.");
-    }
-  }
-
-  private String extraInfoDigest;
-  @Override
-  public String getExtraInfoDigest() {
-    return this.extraInfoDigest;
-  }
-
-  private String extraInfoDigestSha256;
-  @Override
-  public String getExtraInfoDigestSha256() {
-    return this.extraInfoDigestSha256;
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private BandwidthHistory readHistory;
-  @Override
-  public BandwidthHistory getReadHistory() {
-    return this.readHistory;
-  }
-
-  private BandwidthHistory writeHistory;
-  @Override
-  public BandwidthHistory getWriteHistory() {
-    return this.writeHistory;
-  }
-
-  private String geoipDbDigest;
-  @Override
-  public String getGeoipDbDigest() {
-    return this.geoipDbDigest;
-  }
-
-  private String geoip6DbDigest;
-  @Override
-  public String getGeoip6DbDigest() {
-    return this.geoip6DbDigest;
-  }
-
-  private long dirreqStatsEndMillis = -1L;
-  @Override
-  public long getDirreqStatsEndMillis() {
-    return this.dirreqStatsEndMillis;
-  }
-
-  private long dirreqStatsIntervalLength = -1L;
-  @Override
-  public long getDirreqStatsIntervalLength() {
-    return this.dirreqStatsIntervalLength;
-  }
-
-  private String dirreqV2Ips;
-  @Override
-  public SortedMap<String, Integer> getDirreqV2Ips() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV2Ips);
-  }
-
-  private String dirreqV3Ips;
-  @Override
-  public SortedMap<String, Integer> getDirreqV3Ips() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV3Ips);
-  }
-
-  private String dirreqV2Reqs;
-  @Override
-  public SortedMap<String, Integer> getDirreqV2Reqs() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV2Reqs);
-  }
-
-  private String dirreqV3Reqs;
-  @Override
-  public SortedMap<String, Integer> getDirreqV3Reqs() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV3Reqs);
-  }
-
-  private double dirreqV2Share = -1.0;
-  @Override
-  public double getDirreqV2Share() {
-    return this.dirreqV2Share;
-  }
-
-  private double dirreqV3Share = -1.0;
-  @Override
-  public double getDirreqV3Share() {
-    return this.dirreqV3Share;
-  }
-
-  private String dirreqV2Resp;
-  @Override
-  public SortedMap<String, Integer> getDirreqV2Resp() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV2Resp);
-  }
-
-  private String dirreqV3Resp;
-  @Override
-  public SortedMap<String, Integer> getDirreqV3Resp() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV3Resp);
-  }
-
-  private String dirreqV2DirectDl;
-  @Override
-  public SortedMap<String, Integer> getDirreqV2DirectDl() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV2DirectDl);
-  }
-
-  private String dirreqV3DirectDl;
-  @Override
-  public SortedMap<String, Integer> getDirreqV3DirectDl() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV3DirectDl);
-  }
-
-  private String dirreqV2TunneledDl;
-  @Override
-  public SortedMap<String, Integer> getDirreqV2TunneledDl() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV2TunneledDl);
-  }
-
-  private String dirreqV3TunneledDl;
-  @Override
-  public SortedMap<String, Integer> getDirreqV3TunneledDl() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.dirreqV3TunneledDl);
-  }
-
-  private BandwidthHistory dirreqReadHistory;
-  @Override
-  public BandwidthHistory getDirreqReadHistory() {
-    return this.dirreqReadHistory;
-  }
-
-  private BandwidthHistory dirreqWriteHistory;
-  @Override
-  public BandwidthHistory getDirreqWriteHistory() {
-    return this.dirreqWriteHistory;
-  }
-
-  private long entryStatsEndMillis = -1L;
-  @Override
-  public long getEntryStatsEndMillis() {
-    return this.entryStatsEndMillis;
-  }
-
-  private long entryStatsIntervalLength = -1L;
-  @Override
-  public long getEntryStatsIntervalLength() {
-    return this.entryStatsIntervalLength;
-  }
-
-  private String entryIps;
-  @Override
-  public SortedMap<String, Integer> getEntryIps() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.entryIps);
-  }
-
-  private long cellStatsEndMillis = -1L;
-  @Override
-  public long getCellStatsEndMillis() {
-    return this.cellStatsEndMillis;
-  }
-
-  private long cellStatsIntervalLength = -1L;
-  @Override
-  public long getCellStatsIntervalLength() {
-    return this.cellStatsIntervalLength;
-  }
-
-  private Integer[] cellProcessedCells;
-  @Override
-  public List<Integer> getCellProcessedCells() {
-    return this.cellProcessedCells == null ? null :
-        Arrays.asList(this.cellProcessedCells);
-  }
-
-  private Double[] cellQueuedCells;
-  @Override
-  public List<Double> getCellQueuedCells() {
-    return this.cellQueuedCells == null ? null :
-        Arrays.asList(this.cellQueuedCells);
-  }
-
-  private Integer[] cellTimeInQueue;
-  @Override
-  public List<Integer> getCellTimeInQueue() {
-    return this.cellTimeInQueue == null ? null :
-        Arrays.asList(this.cellTimeInQueue);
-  }
-
-  private int cellCircuitsPerDecile = -1;
-  @Override
-  public int getCellCircuitsPerDecile() {
-    return this.cellCircuitsPerDecile;
-  }
-
-  private long connBiDirectStatsEndMillis = -1L;
-  @Override
-  public long getConnBiDirectStatsEndMillis() {
-    return this.connBiDirectStatsEndMillis;
-  }
-
-  private long connBiDirectStatsIntervalLength = -1L;
-  @Override
-  public long getConnBiDirectStatsIntervalLength() {
-    return this.connBiDirectStatsIntervalLength;
-  }
-
-  private int connBiDirectBelow = -1;
-  @Override
-  public int getConnBiDirectBelow() {
-    return this.connBiDirectBelow;
-  }
-
-  private int connBiDirectRead = -1;
-  @Override
-  public int getConnBiDirectRead() {
-    return this.connBiDirectRead;
-  }
-
-  private int connBiDirectWrite = -1;
-  @Override
-  public int getConnBiDirectWrite() {
-    return this.connBiDirectWrite;
-  }
-
-  private int connBiDirectBoth = -1;
-  @Override
-  public int getConnBiDirectBoth() {
-    return this.connBiDirectBoth;
-  }
-
-  private long exitStatsEndMillis = -1L;
-  @Override
-  public long getExitStatsEndMillis() {
-    return this.exitStatsEndMillis;
-  }
-
-  private long exitStatsIntervalLength = -1L;
-  @Override
-  public long getExitStatsIntervalLength() {
-    return this.exitStatsIntervalLength;
-  }
-
-  private SortedMap<String, Long> exitKibibytesWritten;
-  @Override
-  public SortedMap<String, Long> getExitKibibytesWritten() {
-    return this.exitKibibytesWritten == null ? null :
-        new TreeMap<>(this.exitKibibytesWritten);
-  }
-
-  private SortedMap<String, Long> exitKibibytesRead;
-  @Override
-  public SortedMap<String, Long> getExitKibibytesRead() {
-    return this.exitKibibytesRead == null ? null :
-        new TreeMap<>(this.exitKibibytesRead);
-  }
-
-  private SortedMap<String, Long> exitStreamsOpened;
-  @Override
-  public SortedMap<String, Long> getExitStreamsOpened() {
-    return this.exitStreamsOpened == null ? null :
-        new TreeMap<>(this.exitStreamsOpened);
-  }
-
-  private long geoipStartTimeMillis = -1L;
-  @Override
-  public long getGeoipStartTimeMillis() {
-    return this.geoipStartTimeMillis;
-  }
-
-  private String geoipClientOrigins;
-  @Override
-  public SortedMap<String, Integer> getGeoipClientOrigins() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.geoipClientOrigins);
-  }
-
-  private long bridgeStatsEndMillis = -1L;
-  @Override
-  public long getBridgeStatsEndMillis() {
-    return this.bridgeStatsEndMillis;
-  }
-
-  private long bridgeStatsIntervalLength = -1L;
-  @Override
-  public long getBridgeStatsIntervalLength() {
-    return this.bridgeStatsIntervalLength;
-  }
-
-  private String bridgeIps;
-  @Override
-  public SortedMap<String, Integer> getBridgeIps() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.bridgeIps);
-  }
-
-  private String bridgeIpVersions;
-  @Override
-  public SortedMap<String, Integer> getBridgeIpVersions() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.bridgeIpVersions);
-  }
-
-  private String bridgeIpTransports;
-  @Override
-  public SortedMap<String, Integer> getBridgeIpTransports() {
-    return ParseHelper.convertCommaSeparatedKeyIntegerValueList(
-        this.bridgeIpTransports);
-  }
-
-  private List<String> transports = new ArrayList<>();
-  @Override
-  public List<String> getTransports() {
-    return new ArrayList<>(this.transports);
-  }
-
-  private long hidservStatsEndMillis = -1L;
-  @Override
-  public long getHidservStatsEndMillis() {
-    return this.hidservStatsEndMillis;
-  }
-
-  private long hidservStatsIntervalLength = -1L;
-  @Override
-  public long getHidservStatsIntervalLength() {
-    return this.hidservStatsIntervalLength;
-  }
-
-  private Double hidservRendRelayedCells;
-  @Override
-  public Double getHidservRendRelayedCells() {
-    return this.hidservRendRelayedCells;
-  }
-
-  private Map<String, Double> hidservRendRelayedCellsParameters;
-  @Override
-  public Map<String, Double> getHidservRendRelayedCellsParameters() {
-    return this.hidservRendRelayedCellsParameters == null ? null :
-        new HashMap<>(this.hidservRendRelayedCellsParameters);
-  }
-
-  private Double hidservDirOnionsSeen;
-  @Override
-  public Double getHidservDirOnionsSeen() {
-    return this.hidservDirOnionsSeen;
-  }
-
-  private Map<String, Double> hidservDirOnionsSeenParameters;
-  @Override
-  public Map<String, Double> getHidservDirOnionsSeenParameters() {
-    return this.hidservDirOnionsSeenParameters == null ? null :
-      new HashMap<>(this.hidservDirOnionsSeenParameters);
-  }
-
-  private String routerSignature;
-  @Override
-  public String getRouterSignature() {
-    return this.routerSignature;
-  }
-
-  private String identityEd25519;
-  @Override
-  public String getIdentityEd25519() {
-    return this.identityEd25519;
-  }
-
-  private String masterKeyEd25519;
-  @Override
-  public String getMasterKeyEd25519() {
-    return this.masterKeyEd25519;
-  }
-
-  private String routerSignatureEd25519;
-  @Override
-  public String getRouterSignatureEd25519() {
-    return this.routerSignatureEd25519;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/MicrodescriptorImpl.java b/src/org/torproject/descriptor/impl/MicrodescriptorImpl.java
deleted file mode 100644
index 4931c31..0000000
--- a/src/org/torproject/descriptor/impl/MicrodescriptorImpl.java
+++ /dev/null
@@ -1,328 +0,0 @@
-/* Copyright 2014--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.Microdescriptor;
-
-/* Contains a microdescriptor. */
-public class MicrodescriptorImpl extends DescriptorImpl
-    implements Microdescriptor {
-
-  protected static List<Microdescriptor> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<Microdescriptor> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
-        "onion-key\n");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      Microdescriptor parsedDescriptor =
-          new MicrodescriptorImpl(descriptorBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected MicrodescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.parseDescriptorBytes();
-    this.calculateDigest();
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList(
-        "onion-key".split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
-        "ntor-onion-key,family,p,p6,id").split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkFirstKeyword("onion-key");
-    this.clearParsedKeywords();
-    return;
-  }
-
-  private void parseDescriptorBytes() throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
-        useDelimiter("\n");
-    String nextCrypto = "";
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.startsWith("@")) {
-        continue;
-      }
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "onion-key":
-        this.parseOnionKeyLine(line, parts);
-        nextCrypto = "onion-key";
-        break;
-      case "ntor-onion-key":
-        this.parseNtorOnionKeyLine(line, parts);
-        break;
-      case "a":
-        this.parseALine(line, parts);
-        break;
-      case "family":
-        this.parseFamilyLine(line, parts);
-        break;
-      case "p":
-        this.parsePLine(line, parts);
-        break;
-      case "p6":
-        this.parseP6Line(line, parts);
-        break;
-      case "id":
-        this.parseIdLine(line, parts);
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        if (nextCrypto.equals("onion-key")) {
-          this.onionKey = cryptoString;
-        } else {
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in microdescriptor.");
-        }
-        nextCrypto = "";
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else {
-          ParseHelper.parseKeyword(line, parts[0]);
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in microdescriptor.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  private void parseOnionKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("onion-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseNtorOnionKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.ntorOnionKey = parts[1].replaceAll("=", "");
-  }
-
-  private void parseALine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    /* TODO Add more checks. */
-    /* TODO Add tests. */
-    this.orAddresses.add(parts[1]);
-  }
-
-  private void parseFamilyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    String[] familyEntries = new String[parts.length - 1];
-    for (int i = 1; i < parts.length; i++) {
-      if (parts[i].startsWith("$")) {
-        if (parts[i].contains("=") ^ parts[i].contains("~")) {
-          String separator = parts[i].contains("=") ? "=" : "~";
-          String fingerprint = ParseHelper.parseTwentyByteHexString(line,
-              parts[i].substring(1, parts[i].indexOf(separator)));
-          String nickname = ParseHelper.parseNickname(line,
-              parts[i].substring(parts[i].indexOf(separator) + 1));
-          familyEntries[i - 1] = "$" + fingerprint + separator + nickname;
-        } else {
-          familyEntries[i - 1] = "$"
-              + ParseHelper.parseTwentyByteHexString(line,
-              parts[i].substring(1));
-        }
-      } else {
-        familyEntries[i - 1] = ParseHelper.parseNickname(line, parts[i]);
-      }
-    }
-    this.familyEntries = familyEntries;
-  }
-
-  private void parsePLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.validatePOrP6Line(line, parts);
-    this.defaultPolicy = parts[1];
-    this.portList = parts[2];
-  }
-
-  private void parseP6Line(String line, String[] parts)
-      throws DescriptorParseException {
-    this.validatePOrP6Line(line, parts);
-    this.ipv6DefaultPolicy = parts[1];
-    this.ipv6PortList = parts[2];
-  }
-
-  private void validatePOrP6Line(String line, String[] parts)
-      throws DescriptorParseException {
-    boolean isValid = true;
-    if (parts.length != 3) {
-      isValid = false;
-    } else  {
-      switch (parts[1]) {
-      case "accept":
-      case "reject":
-        String[] ports = parts[2].split(",", -1);
-        for (int i = 0; i < ports.length; i++) {
-          if (ports[i].length() < 1) {
-            isValid = false;
-            break;
-          }
-        }
-        break;
-      default:
-        isValid = false;
-      }
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseIdLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 3) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    } else {
-        switch (parts[1]) {
-        case "ed25519":
-          ParseHelper.parseThirtyTwoByteBase64String(line, parts[2]);
-          this.ed25519Identity = parts[2];
-          break;
-        case "rsa1024":
-          ParseHelper.parseTwentyByteBase64String(line, parts[2]);
-          this.rsa1024Identity = parts[2];
-          break;
-        default:
-          throw new DescriptorParseException("Illegal line '" + line + "'.");
-        }
-    }
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "onion-key\n";
-      int start = ascii.indexOf(startToken);
-      int end = ascii.length();
-      if (start >= 0 && end > start) {
-        byte[] forDigest = new byte[end - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, end - start);
-        this.microdescriptorDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-256").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.microdescriptorDigest == null) {
-      throw new DescriptorParseException("Could not calculate "
-          + "microdescriptor digest.");
-    }
-  }
-
-  private String microdescriptorDigest;
-  @Override
-  public String getMicrodescriptorDigest() {
-    return this.microdescriptorDigest;
-  }
-
-  private String onionKey;
-  @Override
-  public String getOnionKey() {
-    return this.onionKey;
-  }
-
-  private String ntorOnionKey;
-  @Override
-  public String getNtorOnionKey() {
-    return this.ntorOnionKey;
-  }
-
-  private List<String> orAddresses = new ArrayList<>();
-  @Override
-  public List<String> getOrAddresses() {
-    return new ArrayList<>(this.orAddresses);
-  }
-
-  private String[] familyEntries;
-  @Override
-  public List<String> getFamilyEntries() {
-    return this.familyEntries == null ? null :
-        Arrays.asList(this.familyEntries);
-  }
-  private String defaultPolicy;
-  @Override
-  public String getDefaultPolicy() {
-    return this.defaultPolicy;
-  }
-
-  private String portList;
-  @Override
-  public String getPortList() {
-    return this.portList;
-  }
-
-  private String ipv6DefaultPolicy;
-  @Override
-  public String getIpv6DefaultPolicy() {
-    return this.ipv6DefaultPolicy;
-  }
-
-  private String ipv6PortList;
-  @Override
-  public String getIpv6PortList() {
-    return this.ipv6PortList;
-  }
-
-  private String rsa1024Identity;
-  @Override
-  public String getRsa1024Identity() {
-    return this.rsa1024Identity;
-  }
-
-  private String ed25519Identity;
-  @Override
-  public String getEd25519Identity() {
-    return this.ed25519Identity;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/NetworkStatusEntryImpl.java b/src/org/torproject/descriptor/impl/NetworkStatusEntryImpl.java
deleted file mode 100644
index b73d211..0000000
--- a/src/org/torproject/descriptor/impl/NetworkStatusEntryImpl.java
+++ /dev/null
@@ -1,382 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.BitSet;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.NetworkStatusEntry;
-
-public class NetworkStatusEntryImpl implements NetworkStatusEntry {
-
-  private byte[] statusEntryBytes;
-  @Override
-  public byte[] getStatusEntryBytes() {
-    return this.statusEntryBytes;
-  }
-
-  private boolean microdescConsensus;
-
-  private boolean failUnrecognizedDescriptorLines;
-  private List<String> unrecognizedLines;
-  protected List<String> getAndClearUnrecognizedLines() {
-    List<String> lines = this.unrecognizedLines;
-    this.unrecognizedLines = null;
-    return lines;
-  }
-
-  protected NetworkStatusEntryImpl(byte[] statusEntryBytes,
-      boolean microdescConsensus, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    this.statusEntryBytes = statusEntryBytes;
-    this.microdescConsensus = microdescConsensus;
-    this.failUnrecognizedDescriptorLines =
-        failUnrecognizedDescriptorLines;
-    this.initializeKeywords();
-    this.parseStatusEntryBytes();
-    this.clearAtMostOnceKeywords();
-  }
-
-  private SortedSet<String> atMostOnceKeywords;
-  private void initializeKeywords() {
-    this.atMostOnceKeywords = new TreeSet<>();
-    this.atMostOnceKeywords.add("s");
-    this.atMostOnceKeywords.add("v");
-    this.atMostOnceKeywords.add("w");
-    this.atMostOnceKeywords.add("p");
-  }
-
-  private void parsedAtMostOnceKeyword(String keyword)
-      throws DescriptorParseException {
-    if (!this.atMostOnceKeywords.contains(keyword)) {
-      throw new DescriptorParseException("Duplicate '" + keyword
-          + "' line in status entry.");
-    }
-    this.atMostOnceKeywords.remove(keyword);
-  }
-
-  private void parseStatusEntryBytes() throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.statusEntryBytes)).
-        useDelimiter("\n");
-    String line = null;
-    if (!s.hasNext() || !(line = s.next()).startsWith("r ")) {
-      throw new DescriptorParseException("Status entry must start with "
-          + "an r line.");
-    }
-    String[] rLineParts = line.split("[ \t]+");
-    this.parseRLine(line, rLineParts);
-    while (s.hasNext()) {
-      line = s.next();
-      String[] parts = !line.startsWith("opt ") ? line.split("[ \t]+") :
-          line.substring("opt ".length()).split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "a":
-        this.parseALine(line, parts);
-        break;
-      case "s":
-        this.parseSLine(line, parts);
-        break;
-      case "v":
-        this.parseVLine(line, parts);
-        break;
-      case "w":
-        this.parseWLine(line, parts);
-        break;
-      case "p":
-        this.parsePLine(line, parts);
-        break;
-      case "m":
-        this.parseMLine(line, parts);
-        break;
-      case "id":
-        this.parseIdLine(line, parts);
-        break;
-      default:
-        if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in status entry.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private void parseRLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if ((!this.microdescConsensus && parts.length != 9) ||
-        (this.microdescConsensus && parts.length != 8)) {
-      throw new DescriptorParseException("r line '" + line + "' has "
-          + "fewer space-separated elements than expected.");
-    }
-    this.nickname = ParseHelper.parseNickname(line, parts[1]);
-    this.fingerprint = ParseHelper.parseTwentyByteBase64String(line,
-        parts[2]);
-    int descriptorOffset = 0;
-    if (!this.microdescConsensus) {
-      this.descriptor = ParseHelper.parseTwentyByteBase64String(line,
-          parts[3]);
-      descriptorOffset = 1;
-    }
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        3 + descriptorOffset, 4 + descriptorOffset);
-    this.address = ParseHelper.parseIpv4Address(line,
-        parts[5 + descriptorOffset]);
-    this.orPort = ParseHelper.parsePort(line,
-        parts[6 + descriptorOffset]);
-    this.dirPort = ParseHelper.parsePort(line,
-        parts[7 + descriptorOffset]);
-  }
-
-  private void parseALine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Invalid line '" + line + "' in "
-          + "status entry.");
-    }
-    /* TODO Add more checks. */
-    /* TODO Add tests. */
-    this.orAddresses.add(parts[1]);
-  }
-
-  private static Map<String, Integer> flagIndexes = new HashMap<>();
-  private static Map<Integer, String> flagStrings = new HashMap<>();
-
-  private void parseSLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.parsedAtMostOnceKeyword("s");
-    BitSet flags = new BitSet(flagIndexes.size());
-    for (int i = 1; i < parts.length; i++) {
-      String flag = parts[i];
-      if (!flagIndexes.containsKey(flag)) {
-        flagStrings.put(flagIndexes.size(), flag);
-        flagIndexes.put(flag, flagIndexes.size());
-      }
-      flags.set(flagIndexes.get(flag));
-    }
-    this.flags = flags;
-  }
-
-  private void parseVLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.parsedAtMostOnceKeyword("v");
-    String noOptLine = line;
-    if (noOptLine.startsWith("opt ")) {
-      noOptLine = noOptLine.substring(4);
-    }
-    if (noOptLine.length() < 3) {
-      throw new DescriptorParseException("Invalid line '" + line + "' in "
-          + "status entry.");
-    } else {
-      this.version = noOptLine.substring(2);
-    }
-  }
-
-  private void parseWLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.parsedAtMostOnceKeyword("w");
-    SortedMap<String, Integer> pairs =
-        ParseHelper.parseKeyValueIntegerPairs(line, parts, 1, "=");
-    if (pairs.isEmpty()) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    if (pairs.containsKey("Bandwidth")) {
-      this.bandwidth = pairs.remove("Bandwidth");
-    }
-    if (pairs.containsKey("Measured")) {
-      this.measured = pairs.remove("Measured");
-    }
-    if (pairs.containsKey("Unmeasured")) {
-      this.unmeasured = pairs.remove("Unmeasured") == 1L;
-    }
-    if (!pairs.isEmpty()) {
-      /* Ignore unknown key-value pair. */
-    }
-  }
-
-  private void parsePLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.parsedAtMostOnceKeyword("p");
-    boolean isValid = true;
-    if (parts.length != 3) {
-      isValid = false;
-    } else {
-        switch (parts[1]) {
-          case "accept":
-          case "reject":
-            this.defaultPolicy = parts[1];
-            this.portList = parts[2];
-            String[] ports = parts[2].split(",", -1);
-            for (int i = 0; i < ports.length; i++) {
-              if (ports[i].length() < 1) {
-                isValid = false;
-                break;
-              }
-            }
-            break;
-          default:
-            isValid = false;
-        }
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseMLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (this.microdescriptorDigests == null) {
-      this.microdescriptorDigests = new HashSet<>();
-    }
-    if (parts.length == 2) {
-      this.microdescriptorDigests.add(
-          ParseHelper.parseThirtyTwoByteBase64String(line, parts[1]));
-    } else if (parts.length == 3 && parts[2].length() > 7) {
-      /* 7 == "sha256=".length() */
-      this.microdescriptorDigests.add(
-          ParseHelper.parseThirtyTwoByteBase64String(line,
-          parts[2].substring(7)));
-    }
-  }
-
-  private void parseIdLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 3 || !"ed25519".equals(parts[1])) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    } else if ("none".equals(parts[2])) {
-      this.masterKeyEd25519 = "none";
-    } else {
-      ParseHelper.parseThirtyTwoByteBase64String(line, parts[2]);
-      this.masterKeyEd25519 = parts[2];
-    }
-  }
-
-  private void clearAtMostOnceKeywords() {
-    this.atMostOnceKeywords = null;
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private String descriptor;
-  @Override
-  public String getDescriptor() {
-    return this.descriptor;
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private String address;
-  @Override
-  public String getAddress() {
-    return this.address;
-  }
-
-  private int orPort;
-  @Override
-  public int getOrPort() {
-    return this.orPort;
-  }
-
-  private int dirPort;
-  @Override
-  public int getDirPort() {
-    return this.dirPort;
-  }
-
-  private Set<String> microdescriptorDigests;
-  @Override
-  public Set<String> getMicrodescriptorDigests() {
-    return this.microdescriptorDigests == null ? null :
-        new HashSet<>(this.microdescriptorDigests);
-  }
-
-  private List<String> orAddresses = new ArrayList<>();
-  @Override
-  public List<String> getOrAddresses() {
-    return new ArrayList<>(this.orAddresses);
-  }
-
-  private BitSet flags;
-  @Override
-  public SortedSet<String> getFlags() {
-    SortedSet<String> result = new TreeSet<>();
-    if (this.flags != null) {
-      for (int i = this.flags.nextSetBit(0); i >= 0;
-          i = this.flags.nextSetBit(i + 1)) {
-        result.add(flagStrings.get(i));
-      }
-    }
-    return result;
-  }
-
-  private String version;
-  @Override
-  public String getVersion() {
-    return this.version;
-  }
-
-  private long bandwidth = -1L;
-  @Override
-  public long getBandwidth() {
-    return this.bandwidth;
-  }
-
-  private long measured = -1L;
-  @Override
-  public long getMeasured() {
-    return this.measured;
-  }
-
-  private boolean unmeasured = false;
-  @Override
-  public boolean getUnmeasured() {
-    return this.unmeasured;
-  }
-
-  private String defaultPolicy;
-  @Override
-  public String getDefaultPolicy() {
-    return this.defaultPolicy;
-  }
-
-  private String portList;
-  @Override
-  public String getPortList() {
-    return this.portList;
-  }
-
-  private String masterKeyEd25519;
-  @Override
-  public String getMasterKeyEd25519() {
-    return this.masterKeyEd25519;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/NetworkStatusImpl.java b/src/org/torproject/descriptor/impl/NetworkStatusImpl.java
deleted file mode 100644
index 5fa22c7..0000000
--- a/src/org/torproject/descriptor/impl/NetworkStatusImpl.java
+++ /dev/null
@@ -1,270 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import org.torproject.descriptor.DirSourceEntry;
-import org.torproject.descriptor.DirectorySignature;
-import org.torproject.descriptor.NetworkStatusEntry;
-
-/* Parse the common parts of v3 consensuses, v3 votes, v3 microdesc
- * consensuses, v2 statuses, and sanitized bridge network statuses and
- * delegate the specific parts to the subclasses. */
-public abstract class NetworkStatusImpl extends DescriptorImpl {
-
-  protected NetworkStatusImpl(byte[] rawDescriptorBytes,
-      boolean failUnrecognizedDescriptorLines,
-      boolean containsDirSourceEntries, boolean blankLinesAllowed)
-      throws DescriptorParseException {
-    super(rawDescriptorBytes, failUnrecognizedDescriptorLines,
-        blankLinesAllowed);
-    this.splitAndParseParts(this.rawDescriptorBytes,
-        containsDirSourceEntries);
-  }
-
-  private void splitAndParseParts(byte[] rawDescriptorBytes,
-      boolean containsDirSourceEntries) throws DescriptorParseException {
-    if (this.rawDescriptorBytes.length == 0) {
-      throw new DescriptorParseException("Descriptor is empty.");
-    }
-    String descriptorString = new String(rawDescriptorBytes);
-    int startIndex = 0;
-    int firstDirSourceIndex = !containsDirSourceEntries ? -1 :
-        this.findFirstIndexOfKeyword(descriptorString, "dir-source");
-    int firstRIndex = this.findFirstIndexOfKeyword(descriptorString, "r");
-    int directoryFooterIndex = this.findFirstIndexOfKeyword(
-        descriptorString, "directory-footer");
-    int firstDirectorySignatureIndex = this.findFirstIndexOfKeyword(
-        descriptorString, "directory-signature");
-    int endIndex = descriptorString.length();
-    if (firstDirectorySignatureIndex < 0) {
-      firstDirectorySignatureIndex = endIndex;
-    }
-    if (directoryFooterIndex < 0) {
-      directoryFooterIndex = firstDirectorySignatureIndex;
-    }
-    if (firstRIndex < 0) {
-      firstRIndex = directoryFooterIndex;
-    }
-    if (firstDirSourceIndex < 0) {
-      firstDirSourceIndex = firstRIndex;
-    }
-    if (firstDirSourceIndex > startIndex) {
-      this.parseHeaderBytes(descriptorString, startIndex,
-          firstDirSourceIndex);
-    }
-    if (firstRIndex > firstDirSourceIndex) {
-      this.parseDirSourceBytes(descriptorString, firstDirSourceIndex,
-          firstRIndex);
-    }
-    if (directoryFooterIndex > firstRIndex) {
-      this.parseStatusEntryBytes(descriptorString, firstRIndex,
-          directoryFooterIndex);
-    }
-    if (firstDirectorySignatureIndex > directoryFooterIndex) {
-      this.parseDirectoryFooterBytes(descriptorString,
-          directoryFooterIndex, firstDirectorySignatureIndex);
-    }
-    if (endIndex > firstDirectorySignatureIndex) {
-      this.parseDirectorySignatureBytes(descriptorString,
-          firstDirectorySignatureIndex, endIndex);
-    }
-  }
-
-  private int findFirstIndexOfKeyword(String descriptorString,
-      String keyword) {
-    if (descriptorString.startsWith(keyword)) {
-      return 0;
-    } else if (descriptorString.contains("\n" + keyword + " ")) {
-      return descriptorString.indexOf("\n" + keyword + " ") + 1;
-    } else if (descriptorString.contains("\n" + keyword + "\n")) {
-      return descriptorString.indexOf("\n" + keyword + "\n") + 1;
-    } else {
-      return -1;
-    }
-  }
-
-  private void parseHeaderBytes(String descriptorString, int start,
-      int end) throws DescriptorParseException {
-    byte[] headerBytes = new byte[end - start];
-    System.arraycopy(this.rawDescriptorBytes, start,
-        headerBytes, 0, end - start);
-    this.parseHeader(headerBytes);
-  }
-
-  private void parseDirSourceBytes(String descriptorString, int start,
-      int end) throws DescriptorParseException {
-    List<byte[]> splitDirSourceBytes =
-        this.splitByKeyword(descriptorString, "dir-source", start, end);
-    for (byte[] dirSourceBytes : splitDirSourceBytes) {
-      this.parseDirSource(dirSourceBytes);
-    }
-  }
-
-  private void parseStatusEntryBytes(String descriptorString, int start,
-      int end) throws DescriptorParseException {
-    List<byte[]> splitStatusEntryBytes =
-        this.splitByKeyword(descriptorString, "r", start, end);
-    for (byte[] statusEntryBytes : splitStatusEntryBytes) {
-      this.parseStatusEntry(statusEntryBytes);
-    }
-  }
-
-  private void parseDirectoryFooterBytes(String descriptorString,
-      int start, int end) throws DescriptorParseException {
-    byte[] directoryFooterBytes = new byte[end - start];
-    System.arraycopy(this.rawDescriptorBytes, start,
-        directoryFooterBytes, 0, end - start);
-    this.parseFooter(directoryFooterBytes);
-  }
-
-  private void parseDirectorySignatureBytes(String descriptorString,
-      int start, int end) throws DescriptorParseException {
-    List<byte[]> splitDirectorySignatureBytes = this.splitByKeyword(
-        descriptorString, "directory-signature", start, end);
-    for (byte[] directorySignatureBytes : splitDirectorySignatureBytes) {
-      this.parseDirectorySignature(directorySignatureBytes);
-    }
-  }
-
-  private List<byte[]> splitByKeyword(String descriptorString,
-      String keyword, int start, int end) {
-    List<byte[]> splitParts = new ArrayList<>();
-    int from = start;
-    while (from < end) {
-      int to = descriptorString.indexOf("\n" + keyword + " ", from);
-      if (to < 0) {
-        to = descriptorString.indexOf("\n" + keyword + "\n", from);
-      }
-      if (to < 0) {
-        to = end;
-      } else {
-        to += 1;
-      }
-      byte[] part = new byte[to - from];
-      System.arraycopy(this.rawDescriptorBytes, from, part, 0,
-          to - from);
-      from = to;
-      splitParts.add(part);
-    }
-    return splitParts;
-  }
-
-  protected abstract void parseHeader(byte[] headerBytes)
-      throws DescriptorParseException;
-
-  protected void parseDirSource(byte[] dirSourceBytes)
-      throws DescriptorParseException {
-    DirSourceEntryImpl dirSourceEntry = new DirSourceEntryImpl(
-        dirSourceBytes, this.failUnrecognizedDescriptorLines);
-    this.dirSourceEntries.put(dirSourceEntry.getIdentity(),
-        dirSourceEntry);
-    List<String> unrecognizedDirSourceLines = dirSourceEntry.
-        getAndClearUnrecognizedLines();
-    if (unrecognizedDirSourceLines != null) {
-      if (this.unrecognizedLines == null) {
-        this.unrecognizedLines = new ArrayList<>();
-      }
-      this.unrecognizedLines.addAll(unrecognizedDirSourceLines);
-    }
-  }
-
-  protected String[] parseClientOrServerVersions(String line,
-      String[] parts) throws DescriptorParseException {
-    String[] result = null;
-    switch (parts.length) {
-      case 1:
-        result = new String[0];
-        break;
-      case 2:
-        result = parts[1].split(",", -1);
-        for (String version : result) {
-          if (version.length() < 1) {
-            throw new DescriptorParseException("Illegal versions line '"
-                + line + "'.");
-          }
-        }
-        break;
-      default:
-        throw new DescriptorParseException("Illegal versions line '" + line
-            + "'.");
-    }
-    return result;
-  }
-
-  protected void parseStatusEntry(byte[] statusEntryBytes)
-      throws DescriptorParseException {
-    NetworkStatusEntryImpl statusEntry = new NetworkStatusEntryImpl(
-        statusEntryBytes, false, this.failUnrecognizedDescriptorLines);
-    this.statusEntries.put(statusEntry.getFingerprint(), statusEntry);
-    List<String> unrecognizedStatusEntryLines = statusEntry.
-        getAndClearUnrecognizedLines();
-    if (unrecognizedStatusEntryLines != null) {
-      if (this.unrecognizedLines == null) {
-        this.unrecognizedLines = new ArrayList<>();
-      }
-      this.unrecognizedLines.addAll(unrecognizedStatusEntryLines);
-    }
-  }
-
-  protected abstract void parseFooter(byte[] footerBytes)
-      throws DescriptorParseException;
-
-  protected void parseDirectorySignature(byte[] directorySignatureBytes)
-      throws DescriptorParseException {
-    if (this.signatures == null) {
-      this.signatures = new ArrayList<>();
-    }
-    DirectorySignatureImpl signature = new DirectorySignatureImpl(
-        directorySignatureBytes, failUnrecognizedDescriptorLines);
-    this.signatures.add(signature);
-    List<String> unrecognizedStatusEntryLines = signature.
-        getAndClearUnrecognizedLines();
-    if (unrecognizedStatusEntryLines != null) {
-      if (this.unrecognizedLines == null) {
-        this.unrecognizedLines = new ArrayList<>();
-      }
-      this.unrecognizedLines.addAll(unrecognizedStatusEntryLines);
-    }
-  }
-
-  protected SortedMap<String, DirSourceEntry> dirSourceEntries =
-      new TreeMap<>();
-  public SortedMap<String, DirSourceEntry> getDirSourceEntries() {
-    return new TreeMap<>(this.dirSourceEntries);
-  }
-
-  protected SortedMap<String, NetworkStatusEntry> statusEntries =
-      new TreeMap<>();
-  public SortedMap<String, NetworkStatusEntry> getStatusEntries() {
-    return new TreeMap<>(this.statusEntries);
-  }
-  public boolean containsStatusEntry(String fingerprint) {
-    return this.statusEntries.containsKey(fingerprint);
-  }
-  public NetworkStatusEntry getStatusEntry(String fingerprint) {
-    return this.statusEntries.get(fingerprint);
-  }
-
-  protected List<DirectorySignature> signatures;
-  public List<DirectorySignature> getSignatures() {
-    return this.signatures == null ? null
-        : new ArrayList<>(this.signatures);
-  }
-  public SortedMap<String, DirectorySignature> getDirectorySignatures() {
-    SortedMap<String, DirectorySignature> directorySignatures = null;
-    if (this.signatures != null) {
-      directorySignatures = new TreeMap<>();
-      for (DirectorySignature signature : this.signatures) {
-        directorySignatures.put(signature.getIdentity(), signature);
-      }
-    }
-    return directorySignatures;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/ParseHelper.java b/src/org/torproject/descriptor/impl/ParseHelper.java
deleted file mode 100644
index 82c0813..0000000
--- a/src/org/torproject/descriptor/impl/ParseHelper.java
+++ /dev/null
@@ -1,567 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import java.util.regex.Pattern;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.DescriptorParseException;
-
-public class ParseHelper {
-
-  private static Pattern keywordPattern =
-      Pattern.compile("^[A-Za-z0-9-]+$");
-  protected static String parseKeyword(String line, String keyword)
-      throws DescriptorParseException {
-    if (!keywordPattern.matcher(keyword).matches()) {
-      throw new DescriptorParseException("Unrecognized character in "
-          + "keyword '" + keyword + "' in line '" + line + "'.");
-    }
-    return keyword;
-  }
-
-  private static Pattern ipv4Pattern =
-      Pattern.compile("^[0-9\\.]{7,15}$");
-  protected static String parseIpv4Address(String line, String address)
-      throws DescriptorParseException {
-    boolean isValid = true;
-    if (!ipv4Pattern.matcher(address).matches()) {
-      isValid = false;
-    } else {
-      String[] parts = address.split("\\.", -1);
-      if (parts.length != 4) {
-        isValid = false;
-      } else {
-        for (int i = 0; i < 4; i++) {
-          try {
-            int octetValue = Integer.parseInt(parts[i]);
-            if (octetValue < 0 || octetValue > 255) {
-              isValid = false;
-            }
-          } catch (NumberFormatException e) {
-            isValid = false;
-          }
-        }
-      }
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("'" + address + "' in line '"
-          + line + "' is not a valid IPv4 address.");
-    }
-    return address;
-  }
-
-  protected static int parsePort(String line, String portString)
-      throws DescriptorParseException {
-    int port = -1;
-    try {
-      port = Integer.parseInt(portString);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("'" + portString + "' in line '"
-          + line + "' is not a valid port number.");
-    }
-    if (port < 0 || port > 65535) {
-      throw new DescriptorParseException("'" + portString + "' in line '"
-          + line + "' is not a valid port number.");
-    }
-    return port;
-  }
-
-  protected static long parseSeconds(String line, String secondsString)
-      throws DescriptorParseException {
-    try {
-      return Long.parseLong(secondsString);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("'" + secondsString + "' in "
-          + "line '" + line + "' is not a valid time in seconds.");
-    }
-  }
-
-  protected static String parseExitPattern(String line, String exitPattern)
-      throws DescriptorParseException {
-    if (!exitPattern.contains(":")) {
-      throw new DescriptorParseException("'" + exitPattern + "' in line '"
-          + line + "' must contain address and port.");
-    }
-    String[] parts = exitPattern.split(":");
-    String addressPart = parts[0];
-    /* TODO Extend to IPv6. */
-    if (addressPart.equals("*")) {
-      /* Nothing to check. */
-    } else if (addressPart.contains("/")) {
-      String[] addressParts = addressPart.split("/");
-      String address = addressParts[0];
-      String mask = addressParts[1];
-      ParseHelper.parseIpv4Address(line, address);
-      if (addressParts.length != 2) {
-        throw new DescriptorParseException("'" + addressPart + "' in "
-            + "line '" + line + "' is not a valid address part.");
-      }
-      if (mask.contains(".")) {
-        ParseHelper.parseIpv4Address(line, mask);
-      } else {
-        int maskValue = -1;
-        try {
-          maskValue = Integer.parseInt(mask);
-        } catch (NumberFormatException e) {
-          /* Handle below. */
-        }
-        if (maskValue < 0 || maskValue > 32) {
-          throw new DescriptorParseException("'" + mask + "' in line '"
-              + line + "' is not a valid IPv4 mask.");
-        }
-      }
-    } else {
-      ParseHelper.parseIpv4Address(line, addressPart);
-    }
-    String portPart = parts[1];
-    if (portPart.equals("*")) {
-      /* Nothing to check. */
-    } else if (portPart.contains("-")) {
-      String[] portParts = portPart.split("-");
-      String fromPort = portParts[0];
-      ParseHelper.parsePort(line, fromPort);
-      String toPort = portParts[1];
-      ParseHelper.parsePort(line, toPort);
-    } else {
-      ParseHelper.parsePort(line, portPart);
-    }
-    return exitPattern;
-  }
-
-  private static ThreadLocal<Map<String, DateFormat>> dateFormats =
-      new ThreadLocal<Map<String, DateFormat>> () {
-    public Map<String, DateFormat> get() {
-      return super.get();
-    }
-    protected Map<String, DateFormat> initialValue() {
-      return new HashMap<>();
-    }
-    public void remove() {
-      super.remove();
-    }
-    public void set(Map<String, DateFormat> value) {
-      super.set(value);
-    }
-  };
-  static DateFormat getDateFormat(String format) {
-    Map<String, DateFormat> threadDateFormats = dateFormats.get();
-    if (!threadDateFormats.containsKey(format)) {
-      DateFormat dateFormat = new SimpleDateFormat(format, Locale.US);
-      dateFormat.setLenient(false);
-      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-      threadDateFormats.put(format, dateFormat);
-    }
-    return threadDateFormats.get(format);
-  }
-
-  protected static long parseTimestampAtIndex(String line, String[] parts,
-      int dateIndex, int timeIndex) throws DescriptorParseException {
-    if (dateIndex >= parts.length || timeIndex >= parts.length) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a timestamp at the expected position.");
-    }
-    long result = -1L;
-    try {
-      DateFormat dateTimeFormat = getDateFormat("yyyy-MM-dd HH:mm:ss");
-      result = dateTimeFormat.parse(
-          parts[dateIndex] + " " + parts[timeIndex]).getTime();
-    } catch (ParseException e) {
-      /* Leave result at -1L. */
-    }
-    if (result < 0L || result / 1000L > (long) Integer.MAX_VALUE) {
-      throw new DescriptorParseException("Illegal timestamp format in "
-          + "line '" + line + "'.");
-    }
-    return result;
-  }
-
-  protected static long parseDateAtIndex(String line, String[] parts,
-      int dateIndex) throws DescriptorParseException {
-    if (dateIndex >= parts.length) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a date at the expected position.");
-    }
-    long result = -1L;
-    try {
-      DateFormat dateFormat = getDateFormat("yyyy-MM-dd");
-      result = dateFormat.parse(parts[dateIndex]).getTime();
-    } catch (ParseException e) {
-      /* Leave result at -1L. */
-    }
-    if (result < 0L || result / 1000L > (long) Integer.MAX_VALUE) {
-      throw new DescriptorParseException("Illegal date format in line '"
-          + line + "'.");
-    }
-    return result;
-  }
-
-  protected static String parseTwentyByteHexString(String line,
-      String hexString) throws DescriptorParseException {
-    return parseHexString(line, hexString, 40);
-  }
-
-  protected static String parseHexString(String line, String hexString)
-      throws DescriptorParseException {
-    return parseHexString(line, hexString, -1);
-  }
-
-  private static Pattern hexPattern = Pattern.compile("^[0-9a-fA-F]*$");
-  private static String parseHexString(String line, String hexString,
-      int expectedLength) throws DescriptorParseException {
-    if (!hexPattern.matcher(hexString).matches() ||
-        hexString.length() % 2 != 0 ||
-        (expectedLength >= 0 && hexString.length() != expectedLength)) {
-      throw new DescriptorParseException("Illegal hex string in line '"
-          + line + "'.");
-    }
-    return hexString.toUpperCase();
-  }
-
-  protected static SortedMap<String, String> parseKeyValueStringPairs(
-      String line, String[] parts, int startIndex, String separatorString)
-      throws DescriptorParseException {
-    SortedMap<String, String> result = new TreeMap<>();
-    for (int i = startIndex; i < parts.length; i++) {
-      String pair = parts[i];
-      String[] pairParts = pair.split(separatorString);
-      if (pairParts.length != 2) {
-        throw new DescriptorParseException("Illegal key-value pair in "
-            + "line '" + line + "'.");
-      }
-      result.put(pairParts[0], pairParts[1]);
-    }
-    return result;
-  }
-
-  protected static SortedMap<String, Integer> parseKeyValueIntegerPairs(
-      String line, String[] parts, int startIndex, String separatorString)
-      throws DescriptorParseException {
-    SortedMap<String, Integer> result = new TreeMap<>();
-    SortedMap<String, String> keyValueStringPairs =
-        ParseHelper.parseKeyValueStringPairs(line, parts, startIndex,
-        separatorString);
-    for (Map.Entry<String, String> e : keyValueStringPairs.entrySet()) {
-      try {
-        result.put(e.getKey(), Integer.parseInt(e.getValue()));
-      } catch (NumberFormatException ex) {
-        throw new DescriptorParseException("Illegal value in line '"
-            + line + "'.");
-      }
-    }
-    return result;
-  }
-
-  private static Pattern nicknamePattern =
-      Pattern.compile("^[0-9a-zA-Z]{1,19}$");
-  protected static String parseNickname(String line, String nickname)
-      throws DescriptorParseException {
-    if (!nicknamePattern.matcher(nickname).matches()) {
-      throw new DescriptorParseException("Illegal nickname in line '"
-          + line + "'.");
-    }
-    return nickname;
-  }
-
-  protected static boolean parseBoolean(String b, String line)
-      throws DescriptorParseException {
-    switch (b) {
-    case "1":
-      return true;
-    case "0":
-      return false;
-    default:
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private static Pattern twentyByteBase64Pattern =
-      Pattern.compile("^[0-9a-zA-Z+/]{27}$");
-  protected static String parseTwentyByteBase64String(String line,
-      String base64String) throws DescriptorParseException {
-    if (!twentyByteBase64Pattern.matcher(base64String).matches()) {
-      throw new DescriptorParseException("'" + base64String
-          + "' in line '" + line + "' is not a valid base64-encoded "
-          + "20-byte value.");
-    }
-    return DatatypeConverter.printHexBinary(
-        DatatypeConverter.parseBase64Binary(base64String + "=")).
-        toUpperCase();
-  }
-
-  private static Pattern thirtyTwoByteBase64Pattern =
-      Pattern.compile("^[0-9a-zA-Z+/]{43}$");
-  protected static String parseThirtyTwoByteBase64String(String line,
-      String base64String) throws DescriptorParseException {
-    if (!thirtyTwoByteBase64Pattern.matcher(base64String).matches()) {
-      throw new DescriptorParseException("'" + base64String
-          + "' in line '" + line + "' is not a valid base64-encoded "
-          + "32-byte value.");
-    }
-    return DatatypeConverter.printHexBinary(
-        DatatypeConverter.parseBase64Binary(base64String + "=")).
-        toUpperCase();
-  }
-
-  private static Map<Integer, Pattern>
-      commaSeparatedKeyValueListPatterns = new HashMap<>();
-  protected static String parseCommaSeparatedKeyIntegerValueList(
-      String line, String[] partsNoOpt, int index, int keyLength)
-      throws DescriptorParseException {
-    String result = "";
-    if (partsNoOpt.length < index) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a key-value list at index " + index + ".");
-    } else if (partsNoOpt.length > index + 1 ) {
-      throw new DescriptorParseException("Line '" + line + "' contains "
-          + "unrecognized values beyond the expected key-value list at "
-          + "index " + index + ".");
-    } else if (partsNoOpt.length > index) {
-      if (!commaSeparatedKeyValueListPatterns.containsKey(keyLength)) {
-        String keyPattern = "[0-9a-zA-Z?<>\\-_]"
-            + (keyLength == 0 ? "+" : "{" + keyLength + "}");
-        String valuePattern = "\\-?[0-9]{1,9}";
-        String patternString = String.format("^%s=%s(,%s=%s)*$",
-            keyPattern, valuePattern, keyPattern, valuePattern);
-        commaSeparatedKeyValueListPatterns.put(keyLength,
-            Pattern.compile(patternString));
-      }
-      Pattern pattern = commaSeparatedKeyValueListPatterns.get(
-          keyLength);
-      if (pattern.matcher(partsNoOpt[index]).matches()) {
-        result = partsNoOpt[index];
-      } else {
-        throw new DescriptorParseException("Line '" + line + "' "
-            + "contains an illegal key or value.");
-      }
-    }
-    return result;
-  }
-
-  protected static SortedMap<String, Integer>
-      convertCommaSeparatedKeyIntegerValueList(String validatedString) {
-    SortedMap<String, Integer> result = null;
-    if (validatedString != null) {
-      result = new TreeMap<>();
-      if (validatedString.contains("=")) {
-        for (String listElement : validatedString.split(",", -1)) {
-          String[] keyAndValue = listElement.split("=");
-          result.put(keyAndValue[0], Integer.parseInt(keyAndValue[1]));
-        }
-      }
-    }
-    return result;
-  }
-
-  protected static SortedMap<String, Long>
-      parseCommaSeparatedKeyLongValueList(String line,
-      String[] partsNoOpt, int index, int keyLength)
-      throws DescriptorParseException {
-    SortedMap<String, Long> result = new TreeMap<>();
-    if (partsNoOpt.length < index) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a key-value list at index " + index + ".");
-    } else if (partsNoOpt.length > index + 1 ) {
-      throw new DescriptorParseException("Line '" + line + "' contains "
-          + "unrecognized values beyond the expected key-value list at "
-          + "index " + index + ".");
-    } else if (partsNoOpt.length > index) {
-      String[] listElements = partsNoOpt[index].split(",", -1);
-      for (String listElement : listElements) {
-        String[] keyAndValue = listElement.split("=");
-        String key = null;
-        long value = -1;
-        if (keyAndValue.length == 2 && (keyLength == 0 ||
-            keyAndValue[0].length() == keyLength)) {
-          try {
-            value = Long.parseLong(keyAndValue[1]);
-            key = keyAndValue[0];
-          } catch (NumberFormatException e) {
-            /* Handle below. */
-          }
-        }
-        if (key == null) {
-          throw new DescriptorParseException("Line '" + line + "' "
-              + "contains an illegal key or value in list element '"
-              + listElement + "'.");
-        }
-        result.put(key, value);
-      }
-    }
-    return result;
-  }
-
-  protected static Integer[] parseCommaSeparatedIntegerValueList(
-      String line, String[] partsNoOpt, int index)
-      throws DescriptorParseException {
-    Integer[] result = null;
-    if (partsNoOpt.length < index) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a comma-separated value list at index " + index
-          + ".");
-    } else if (partsNoOpt.length > index + 1) {
-      throw new DescriptorParseException("Line '" + line + "' contains "
-          + "unrecognized values beyond the expected comma-separated "
-          + "value list at index " + index + ".");
-    } else if (partsNoOpt.length > index) {
-      String[] listElements = partsNoOpt[index].split(",", -1);
-      result = new Integer[listElements.length];
-      for (int i = 0; i < listElements.length; i++) {
-        try {
-          result[i] = Integer.parseInt(listElements[i]);
-        } catch (NumberFormatException e) {
-          throw new DescriptorParseException("Line '" + line + "' "
-              + "contains an illegal value in list element '"
-              + listElements[i] + "'.");
-        }
-      }
-    }
-    return result;
-  }
-
-  protected static Double[] parseCommaSeparatedDoubleValueList(
-      String line, String[] partsNoOpt, int index)
-      throws DescriptorParseException {
-    Double[] result = null;
-    if (partsNoOpt.length < index) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a comma-separated value list at index " + index
-          + ".");
-    } else if (partsNoOpt.length > index + 1) {
-      throw new DescriptorParseException("Line '" + line + "' contains "
-          + "unrecognized values beyond the expected comma-separated "
-          + "value list at index " + index + ".");
-    } else if (partsNoOpt.length > index) {
-      String[] listElements = partsNoOpt[index].split(",", -1);
-      result = new Double[listElements.length];
-      for (int i = 0; i < listElements.length; i++) {
-        try {
-          result[i] = Double.parseDouble(listElements[i]);
-        } catch (NumberFormatException e) {
-          throw new DescriptorParseException("Line '" + line + "' "
-              + "contains an illegal value in list element '"
-              + listElements[i] + "'.");
-        }
-      }
-    }
-    return result;
-  }
-
-  protected static Map<String, Double>
-      parseSpaceSeparatedStringKeyDoubleValueMap(String line,
-      String[] partsNoOpt, int startIndex)
-      throws DescriptorParseException {
-    Map<String, Double> result = new LinkedHashMap<>();
-    if (partsNoOpt.length < startIndex) {
-      throw new DescriptorParseException("Line '" + line + "' does not "
-          + "contain a key-value list starting at index " + startIndex
-          + ".");
-    }
-    for (int i = startIndex; i < partsNoOpt.length; i++) {
-      String listElement = partsNoOpt[i];
-      String[] keyAndValue = listElement.split("=");
-      String key = null;
-      Double value = null;
-      if (keyAndValue.length == 2) {
-        try {
-          value = Double.parseDouble(keyAndValue[1]);
-          key = keyAndValue[0];
-        } catch (NumberFormatException e) {
-          /* Handle below. */
-        }
-      }
-      if (key == null) {
-        throw new DescriptorParseException("Line '" + line + "' contains "
-            + "an illegal key or value in list element '" + listElement
-            + "'.");
-      }
-      result.put(key, value);
-    }
-    return result;
-  }
-
-  protected static String
-      parseMasterKeyEd25519FromIdentityEd25519CryptoBlock(
-      String identityEd25519CryptoBlock) throws DescriptorParseException {
-    String identityEd25519CryptoBlockNoNewlines =
-        identityEd25519CryptoBlock.replaceAll("\n", "");
-    String beginEd25519CertLine = "-----BEGIN ED25519 CERT-----",
-        endEd25519CertLine = "-----END ED25519 CERT-----";
-    if (!identityEd25519CryptoBlockNoNewlines.startsWith(
-        beginEd25519CertLine)) {
-      throw new DescriptorParseException("Illegal start of "
-          + "identity-ed25519 crypto block '" + identityEd25519CryptoBlock
-          + "'.");
-    }
-    if (!identityEd25519CryptoBlockNoNewlines.endsWith(
-        endEd25519CertLine)) {
-      throw new DescriptorParseException("Illegal end of "
-          + "identity-ed25519 crypto block '" + identityEd25519CryptoBlock
-          + "'.");
-    }
-    String identityEd25519Base64 = identityEd25519CryptoBlockNoNewlines.
-        substring(beginEd25519CertLine.length(),
-        identityEd25519CryptoBlock.length()
-        - endEd25519CertLine.length()).replaceAll("=", "");
-    byte[] identityEd25519 = DatatypeConverter.parseBase64Binary(
-        identityEd25519Base64);
-    if (identityEd25519.length < 40) {
-      throw new DescriptorParseException("Invalid length of "
-          + "identity-ed25519 (in bytes): " + identityEd25519.length);
-    } else if (identityEd25519[0] != 0x01) {
-      throw new DescriptorParseException("Unknown version in "
-          + "identity-ed25519: " + identityEd25519[0]);
-    } else if (identityEd25519[1] != 0x04) {
-      throw new DescriptorParseException("Unknown cert type in "
-          + "identity-ed25519: " + identityEd25519[1]);
-    } else if (identityEd25519[6] != 0x01) {
-      throw new DescriptorParseException("Unknown certified key type in "
-          + "identity-ed25519: " + identityEd25519[1]);
-    } else if (identityEd25519[39] == 0x00) {
-      throw new DescriptorParseException("No extensions in "
-          + "identity-ed25519 (which would contain the encoded "
-          + "master-key-ed25519): " + identityEd25519[39]);
-    } else {
-      int extensionStart = 40;
-      for (int i = 0; i < (int) identityEd25519[39]; i++) {
-        if (identityEd25519.length < extensionStart + 4) {
-          throw new DescriptorParseException("Invalid extension with id "
-              + i + " in identity-ed25519.");
-        }
-        int extensionLength = identityEd25519[extensionStart];
-        extensionLength <<= 8;
-        extensionLength += identityEd25519[extensionStart + 1];
-        int extensionType = identityEd25519[extensionStart + 2];
-        if (extensionLength == 32 && extensionType == 4) {
-          if (identityEd25519.length < extensionStart + 4 + 32) {
-            throw new DescriptorParseException("Invalid extension with "
-                + "id " + i + " in identity-ed25519.");
-          }
-          byte[] masterKeyEd25519 = new byte[32];
-          System.arraycopy(identityEd25519, extensionStart + 4,
-              masterKeyEd25519, 0, masterKeyEd25519.length);
-          String masterKeyEd25519Base64 = DatatypeConverter.
-              printBase64Binary(masterKeyEd25519).replaceAll("=", "");
-          String masterKeyEd25519Base64NoTrailingEqualSigns =
-              masterKeyEd25519Base64.replaceAll("=", "");
-          return masterKeyEd25519Base64NoTrailingEqualSigns;
-        }
-        extensionStart += 4 + extensionLength;
-      }
-    }
-    throw new DescriptorParseException("Unable to locate "
-        + "master-key-ed25519 in identity-ed25519.");
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RelayDirectoryImpl.java b/src/org/torproject/descriptor/impl/RelayDirectoryImpl.java
deleted file mode 100644
index 1ff15cb..0000000
--- a/src/org/torproject/descriptor/impl/RelayDirectoryImpl.java
+++ /dev/null
@@ -1,547 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.RelayDirectory;
-import org.torproject.descriptor.RouterStatusEntry;
-import org.torproject.descriptor.ServerDescriptor;
-
-/* TODO Write unit tests. */
-
-public class RelayDirectoryImpl extends DescriptorImpl
-    implements RelayDirectory {
-
-  protected static List<RelayDirectory> parseDirectories(
-      byte[] directoriesBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<RelayDirectory> parsedDirectories = new ArrayList<>();
-    List<byte[]> splitDirectoriesBytes =
-        DescriptorImpl.splitRawDescriptorBytes(directoriesBytes,
-        "signed-directory\n");
-    for (byte[] directoryBytes : splitDirectoriesBytes) {
-      RelayDirectory parsedDirectory =
-          new RelayDirectoryImpl(directoryBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDirectories.add(parsedDirectory);
-    }
-    return parsedDirectories;
-  }
-
-  protected RelayDirectoryImpl(byte[] directoryBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(directoryBytes, failUnrecognizedDescriptorLines, true);
-    this.splitAndParseParts(rawDescriptorBytes);
-    this.calculateDigest();
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
-        "signed-directory,recommended-software,"
-        + "directory-signature").split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList(
-        "dir-signing-key,running-routers,router-status".split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkFirstKeyword("signed-directory");
-    this.clearParsedKeywords();
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "signed-directory\n";
-      String sigToken = "\ndirectory-signature ";
-      if (!ascii.contains(sigToken)) {
-        return;
-      }
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      sig = ascii.indexOf("\n", sig) + 1;
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, sig - start);
-        this.directoryDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-1").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.directoryDigest == null) {
-      throw new DescriptorParseException("Could not calculate v1 "
-          + "directory digest.");
-    }
-  }
-
-  private void splitAndParseParts(byte[] rawDescriptorBytes)
-      throws DescriptorParseException {
-    if (this.rawDescriptorBytes.length == 0) {
-      throw new DescriptorParseException("Descriptor is empty.");
-    }
-    String descriptorString = new String(rawDescriptorBytes);
-    int startIndex = 0;
-    int firstRouterIndex = this.findFirstIndexOfKeyword(descriptorString,
-        "router");
-    int directorySignatureIndex = this.findFirstIndexOfKeyword(
-        descriptorString, "directory-signature");
-    int endIndex = descriptorString.length();
-    if (directorySignatureIndex < 0) {
-      directorySignatureIndex = endIndex;
-    }
-    if (firstRouterIndex < 0) {
-      firstRouterIndex = directorySignatureIndex;
-    }
-    if (firstRouterIndex > startIndex) {
-      this.parseHeaderBytes(descriptorString, startIndex,
-          firstRouterIndex);
-    }
-    if (directorySignatureIndex > firstRouterIndex) {
-      this.parseServerDescriptorBytes(descriptorString, firstRouterIndex,
-          directorySignatureIndex);
-    }
-    if (endIndex > directorySignatureIndex) {
-      this.parseDirectorySignatureBytes(descriptorString,
-          directorySignatureIndex, endIndex);
-    }
-  }
-
-  private int findFirstIndexOfKeyword(String descriptorString,
-      String keyword) {
-    if (descriptorString.startsWith(keyword)) {
-      return 0;
-    } else if (descriptorString.contains("\n" + keyword + " ")) {
-      return descriptorString.indexOf("\n" + keyword + " ") + 1;
-    } else if (descriptorString.contains("\n" + keyword + "\n")) {
-      return descriptorString.indexOf("\n" + keyword + "\n") + 1;
-    } else {
-      return -1;
-    }
-  }
-
-  private void parseHeaderBytes(String descriptorString, int start,
-      int end) throws DescriptorParseException {
-    byte[] headerBytes = new byte[end - start];
-    System.arraycopy(this.rawDescriptorBytes, start,
-        headerBytes, 0, end - start);
-    this.parseHeader(headerBytes);
-  }
-
-  private void parseServerDescriptorBytes(String descriptorString,
-      int start, int end) throws DescriptorParseException {
-    List<byte[]> splitServerDescriptorBytes =
-        this.splitByKeyword(descriptorString, "router", start, end);
-    for (byte[] statusEntryBytes : splitServerDescriptorBytes) {
-      this.parseServerDescriptor(statusEntryBytes);
-    }
-  }
-
-  private void parseDirectorySignatureBytes(String descriptorString,
-      int start, int end) throws DescriptorParseException {
-    List<byte[]> splitDirectorySignatureBytes = this.splitByKeyword(
-        descriptorString, "directory-signature", start, end);
-    for (byte[] directorySignatureBytes : splitDirectorySignatureBytes) {
-      this.parseDirectorySignature(directorySignatureBytes);
-    }
-  }
-
-  private List<byte[]> splitByKeyword(String descriptorString,
-      String keyword, int start, int end) {
-    List<byte[]> splitParts = new ArrayList<>();
-    int from = start;
-    while (from < end) {
-      int to = descriptorString.indexOf("\n" + keyword + " ", from);
-      if (to < 0) {
-        to = descriptorString.indexOf("\n" + keyword + "\n", from);
-      }
-      if (to < 0) {
-        to = end;
-      } else {
-        to += 1;
-      }
-      int toNoNewline = to;
-      while (toNoNewline > from &&
-          descriptorString.charAt(toNoNewline - 1) == '\n') {
-        toNoNewline--;
-      }
-      byte[] part = new byte[toNoNewline - from];
-      System.arraycopy(this.rawDescriptorBytes, from, part, 0,
-          toNoNewline - from);
-      from = to;
-      splitParts.add(part);
-    }
-    return splitParts;
-  }
-
-  private void parseHeader(byte[] headerBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
-    String publishedLine = null, nextCrypto = "",
-        runningRoutersLine = null, routerStatusLine = null;
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.isEmpty() || line.startsWith("@")) {
-        continue;
-      }
-      String lineNoOpt = line.startsWith("opt ") ?
-          line.substring("opt ".length()) : line;
-      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
-      String keyword = partsNoOpt[0];
-      switch (keyword) {
-      case "signed-directory":
-        this.parseSignedDirectoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "published":
-        if (publishedLine != null) {
-          throw new DescriptorParseException("Keyword 'published' is "
-              + "contained more than once, but must be contained exactly "
-              + "once.");
-        } else {
-          publishedLine = line;
-        }
-        break;
-      case "dir-signing-key":
-        this.parseDirSigningKeyLine(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "dir-signing-key";
-        break;
-      case "recommended-software":
-        this.parseRecommendedSoftwareLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "running-routers":
-        runningRoutersLine = line;
-        break;
-      case "router-status":
-        routerStatusLine = line;
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        if (nextCrypto.equals("dir-signing-key") &&
-            this.dirSigningKey == null) {
-          this.dirSigningKey = cryptoString;
-        } else {
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in v1 directory.");
-        }
-        nextCrypto = "";
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else {
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in v1 directory.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-    if (publishedLine == null) {
-      throw new DescriptorParseException("Keyword 'published' is "
-          + "contained 0 times, but must be contained exactly once.");
-    } else {
-      String publishedLineNoOpt = publishedLine.startsWith("opt ") ?
-          publishedLine.substring("opt ".length()) : publishedLine;
-      String[] publishedPartsNoOpt = publishedLineNoOpt.split("[ \t]+");
-      this.parsePublishedLine(publishedLine, publishedLineNoOpt,
-          publishedPartsNoOpt);
-    }
-    if (routerStatusLine != null) {
-      String routerStatusLineNoOpt = routerStatusLine.startsWith("opt ") ?
-          routerStatusLine.substring("opt ".length()) : routerStatusLine;
-      String[] routerStatusPartsNoOpt =
-          routerStatusLineNoOpt.split("[ \t]+");
-      this.parseRouterStatusLine(routerStatusLine, routerStatusLineNoOpt,
-          routerStatusPartsNoOpt);
-    } else if (runningRoutersLine != null) {
-      String runningRoutersLineNoOpt =
-          runningRoutersLine.startsWith("opt ") ?
-          runningRoutersLine.substring("opt ".length()) :
-          runningRoutersLine;
-      String[] runningRoutersPartsNoOpt =
-          runningRoutersLineNoOpt.split("[ \t]+");
-      this.parseRunningRoutersLine(runningRoutersLine,
-          runningRoutersLineNoOpt, runningRoutersPartsNoOpt);
-    } else {
-      throw new DescriptorParseException("Either running-routers or "
-          + "router-status line must be given.");
-    }
-  }
-
-  protected void parseServerDescriptor(byte[] serverDescriptorBytes) {
-    try {
-      ServerDescriptorImpl serverDescriptor =
-          new RelayServerDescriptorImpl(serverDescriptorBytes,
-          this.failUnrecognizedDescriptorLines);
-      this.serverDescriptors.add(serverDescriptor);
-    } catch (DescriptorParseException e) {
-      this.serverDescriptorParseExceptions.add(e);
-    }
-  }
-
-  private void parseDirectorySignature(byte[] directorySignatureBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(directorySignatureBytes)).
-        useDelimiter("\n");
-    String nextCrypto = "";
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      String lineNoOpt = line.startsWith("opt ") ?
-          line.substring("opt ".length()) : line;
-      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
-      String keyword = partsNoOpt[0];
-      switch (keyword) {
-      case "directory-signature":
-        this.parseDirectorySignatureLine(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "directory-signature";
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        if (nextCrypto.equals("directory-signature")) {
-          this.directorySignature = cryptoString;
-        } else {
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in v2 network status.");
-        }
-        nextCrypto = "";
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in v2 network status.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private void parseSignedDirectoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("signed-directory")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parsePublishedLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseDirSigningKeyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length > 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    } else if (partsNoOpt.length == 2) {
-      /* Early directories didn't have a crypto object following the
-       * "dir-signing-key" line, but had the key base64-encoded in the
-       * same line. */
-      StringBuilder sb = new StringBuilder();
-      sb.append("-----BEGIN RSA PUBLIC KEY-----\n");
-      String keyString = partsNoOpt[1];
-      while (keyString.length() > 64) {
-        sb.append(keyString.substring(0, 64)).append("\n");
-        keyString = keyString.substring(64);
-      }
-      if (keyString.length() > 0) {
-        sb.append(keyString).append("\n");
-      }
-      sb.append("-----END RSA PUBLIC KEY-----\n");
-      this.dirSigningKey = sb.toString();
-    }
-  }
-
-  private void parseRecommendedSoftwareLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    List<String> result = new ArrayList<>();
-    if (partsNoOpt.length > 2) {
-      throw new DescriptorParseException("Illegal versions line '" + line
-          + "'.");
-    } else if (partsNoOpt.length == 2) {
-      String[] versions = partsNoOpt[1].split(",", -1);
-      for (int i = 0; i < versions.length; i++) {
-        String version = versions[i];
-        if (version.length() < 1) {
-          throw new DescriptorParseException("Illegal versions line '"
-              + line + "'.");
-        }
-        result.add(version);
-      }
-    }
-    this.recommendedSoftware = result;
-  }
-
-  private void parseRunningRoutersLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    for (int i = 1; i < partsNoOpt.length; i++) {
-      String part = partsNoOpt[i];
-      String debugLine = "running-routers [...] " + part + " [...]";
-      boolean isLive = true;
-      if (part.startsWith("!")) {
-        isLive = false;
-        part = part.substring(1);
-      }
-      boolean isVerified;
-      String fingerprint = null, nickname = null;
-      if (part.startsWith("$")) {
-        isVerified = false;
-        fingerprint = ParseHelper.parseTwentyByteHexString(debugLine,
-            part.substring(1));
-      } else {
-        isVerified = true;
-        nickname = ParseHelper.parseNickname(debugLine, part);
-      }
-      this.statusEntries.add(new RouterStatusEntryImpl(fingerprint,
-          nickname, isLive, isVerified));
-    }
-  }
-
-  private void parseRouterStatusLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    for (int i = 1; i < partsNoOpt.length; i++) {
-      String part = partsNoOpt[i];
-      String debugLine = "router-status [...] " + part + " [...]";
-      RouterStatusEntry entry = null;
-      if (part.contains("=")) {
-        String[] partParts = part.split("=");
-        if (partParts.length == 2) {
-          boolean isVerified = true, isLive;
-          String nickname;
-          if (partParts[0].startsWith("!")) {
-            isLive = false;
-            nickname = ParseHelper.parseNickname(debugLine,
-                partParts[0].substring(1));
-          } else {
-            isLive = true;
-            nickname = ParseHelper.parseNickname(debugLine, partParts[0]);
-          }
-          String fingerprint = ParseHelper.parseTwentyByteHexString(
-              debugLine, partParts[1].substring(1));
-          entry = new RouterStatusEntryImpl(fingerprint, nickname, isLive,
-              isVerified);
-        }
-      } else {
-        boolean isVerified = false, isLive;
-        String nickname = null, fingerprint;
-        if (part.startsWith("!")) {
-          isLive = false;
-          fingerprint = ParseHelper.parseTwentyByteHexString(
-              debugLine, part.substring(2));
-        } else {
-          isLive = true;
-          fingerprint = ParseHelper.parseTwentyByteHexString(
-              debugLine, part.substring(1));;
-        }
-        entry = new RouterStatusEntryImpl(fingerprint, nickname, isLive,
-            isVerified);
-      }
-      if (entry == null) {
-        throw new DescriptorParseException("Illegal router-status entry '"
-            + part + "' in v1 directory.");
-      }
-      this.statusEntries.add(entry);
-    }
-  }
-
-  private void parseDirectorySignatureLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.nickname = ParseHelper.parseNickname(line, partsNoOpt[1]);
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private String dirSigningKey;
-  @Override
-  public String getDirSigningKey() {
-    return this.dirSigningKey;
-  }
-
-  private List<String> recommendedSoftware;
-  @Override
-  public List<String> getRecommendedSoftware() {
-    return this.recommendedSoftware == null ? null :
-        new ArrayList<>(this.recommendedSoftware);
-  }
-
-  private String directorySignature;
-  @Override
-  public String getDirectorySignature() {
-    return this.directorySignature;
-  }
-
-  private List<RouterStatusEntry> statusEntries = new ArrayList<>();
-  @Override
-  public List<RouterStatusEntry> getRouterStatusEntries() {
-    return new ArrayList<>(this.statusEntries);
-  }
-
-  private List<ServerDescriptor> serverDescriptors = new ArrayList<>();
-  @Override
-  public List<ServerDescriptor> getServerDescriptors() {
-    return new ArrayList<>(this.serverDescriptors);
-  }
-
-  private List<Exception> serverDescriptorParseExceptions =
-      new ArrayList<>();
-  @Override
-  public List<Exception> getServerDescriptorParseExceptions() {
-    return new ArrayList<>(this.serverDescriptorParseExceptions);
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String directoryDigest;
-  @Override
-  public String getDirectoryDigest() {
-    return this.directoryDigest;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RelayExtraInfoDescriptorImpl.java b/src/org/torproject/descriptor/impl/RelayExtraInfoDescriptorImpl.java
deleted file mode 100644
index 73d4dfa..0000000
--- a/src/org/torproject/descriptor/impl/RelayExtraInfoDescriptorImpl.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-import org.torproject.descriptor.RelayExtraInfoDescriptor;
-
-public class RelayExtraInfoDescriptorImpl
-    extends ExtraInfoDescriptorImpl implements RelayExtraInfoDescriptor {
-
-  protected static List<ExtraInfoDescriptor> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<ExtraInfoDescriptor> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
-        "extra-info ");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      ExtraInfoDescriptor parsedDescriptor =
-          new RelayExtraInfoDescriptorImpl(descriptorBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected RelayExtraInfoDescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImpl.java b/src/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImpl.java
deleted file mode 100644
index fe045c1..0000000
--- a/src/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImpl.java
+++ /dev/null
@@ -1,414 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-
-/* Contains a network status consensus or microdesc consensus. */
-public class RelayNetworkStatusConsensusImpl extends NetworkStatusImpl
-    implements RelayNetworkStatusConsensus {
-
-  protected static List<RelayNetworkStatusConsensus> parseConsensuses(
-      byte[] consensusesBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<RelayNetworkStatusConsensus> parsedConsensuses =
-        new ArrayList<>();
-    List<byte[]> splitConsensusBytes =
-        DescriptorImpl.splitRawDescriptorBytes(consensusesBytes,
-        "network-status-version 3");
-    for (byte[] consensusBytes : splitConsensusBytes) {
-      RelayNetworkStatusConsensus parsedConsensus =
-          new RelayNetworkStatusConsensusImpl(consensusBytes,
-              failUnrecognizedDescriptorLines);
-      parsedConsensuses.add(parsedConsensus);
-    }
-    return parsedConsensuses;
-  }
-
-  protected RelayNetworkStatusConsensusImpl(byte[] consensusBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(consensusBytes, failUnrecognizedDescriptorLines, true, false);
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
-        "vote-status,consensus-method,valid-after,fresh-until,"
-        + "valid-until,voting-delay,known-flags").split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
-        "client-versions,server-versions,params,directory-footer,"
-        + "bandwidth-weights").split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkFirstKeyword("network-status-version");
-    this.clearParsedKeywords();
-    this.calculateDigest();
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "network-status-version ";
-      String sigToken = "\ndirectory-signature ";
-      if (!ascii.contains(sigToken)) {
-        return;
-      }
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, sig - start);
-        this.consensusDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-1").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.consensusDigest == null) {
-      throw new DescriptorParseException("Could not calculate consensus "
-          + "digest.");
-    }
-  }
-
-  protected void parseHeader(byte[] headerBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "network-status-version":
-        this.parseNetworkStatusVersionLine(line, parts);
-        break;
-      case "vote-status":
-        this.parseVoteStatusLine(line, parts);
-        break;
-      case "consensus-method":
-        this.parseConsensusMethodLine(line, parts);
-        break;
-      case "valid-after":
-        this.parseValidAfterLine(line, parts);
-        break;
-      case "fresh-until":
-        this.parseFreshUntilLine(line, parts);
-        break;
-      case "valid-until":
-        this.parseValidUntilLine(line, parts);
-        break;
-      case "voting-delay":
-        this.parseVotingDelayLine(line, parts);
-        break;
-      case "client-versions":
-        this.parseClientVersionsLine(line, parts);
-        break;
-      case "server-versions":
-        this.parseServerVersionsLine(line, parts);
-        break;
-      case "package":
-        this.parsePackageLine(line, parts);
-        break;
-      case "known-flags":
-        this.parseKnownFlagsLine(line, parts);
-        break;
-      case "params":
-        this.parseParamsLine(line, parts);
-        break;
-      default:
-        if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in consensus.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private boolean microdescConsensus = false;
-  protected void parseStatusEntry(byte[] statusEntryBytes)
-      throws DescriptorParseException {
-    NetworkStatusEntryImpl statusEntry = new NetworkStatusEntryImpl(
-        statusEntryBytes, this.microdescConsensus,
-        this.failUnrecognizedDescriptorLines);
-    this.statusEntries.put(statusEntry.getFingerprint(), statusEntry);
-    List<String> unrecognizedStatusEntryLines = statusEntry.
-        getAndClearUnrecognizedLines();
-    if (unrecognizedStatusEntryLines != null) {
-      if (this.unrecognizedLines == null) {
-        this.unrecognizedLines = new ArrayList<>();
-      }
-      this.unrecognizedLines.addAll(unrecognizedStatusEntryLines);
-    }
-  }
-
-  protected void parseFooter(byte[] footerBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(footerBytes)).useDelimiter("\n");
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "directory-footer":
-        break;
-      case "bandwidth-weights":
-        this.parseBandwidthWeightsLine(line, parts);
-        break;
-      default:
-        if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in consensus.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private void parseNetworkStatusVersionLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.startsWith("network-status-version 3")) {
-      throw new DescriptorParseException("Illegal network status version "
-          + "number in line '" + line + "'.");
-    }
-    this.networkStatusVersion = 3;
-    if (parts.length == 3) {
-      this.consensusFlavor = parts[2];
-      if (this.consensusFlavor.equals("microdesc")) {
-        this.microdescConsensus = true;
-      }
-    } else if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal network status version "
-          + "line '" + line + "'.");
-    }
-  }
-
-  private void parseVoteStatusLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2 || !parts[1].equals("consensus")) {
-      throw new DescriptorParseException("Line '" + line + "' indicates "
-          + "that this is not a consensus.");
-    }
-  }
-
-  private void parseConsensusMethodLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in consensus.");
-    }
-    try {
-      this.consensusMethod = Integer.parseInt(parts[1]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal consensus method "
-          + "number in line '" + line + "'.");
-    }
-    if (this.consensusMethod < 1) {
-      throw new DescriptorParseException("Illegal consensus method "
-          + "number in line '" + line + "'.");
-    }
-  }
-
-  private void parseValidAfterLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.validAfterMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseFreshUntilLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.freshUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseValidUntilLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.validUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseVotingDelayLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 3) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    try {
-      this.voteSeconds = Long.parseLong(parts[1]);
-      this.distSeconds = Long.parseLong(parts[2]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal values in line '" + line
-          + "'.");
-    }
-  }
-
-  private void parseClientVersionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.recommendedClientVersions = this.parseClientOrServerVersions(
-        line, parts);
-  }
-
-  private void parseServerVersionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.recommendedServerVersions = this.parseClientOrServerVersions(
-        line, parts);
-  }
-
-  private void parsePackageLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 5) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    if (this.packageLines == null) {
-      this.packageLines = new ArrayList<>();
-    }
-    this.packageLines.add(line.substring("package ".length()));
-  }
-
-  private void parseKnownFlagsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 2) {
-      throw new DescriptorParseException("No known flags in line '" + line
-          + "'.");
-    }
-    String[] knownFlags = new String[parts.length - 1];
-    for (int i = 1; i < parts.length; i++) {
-      knownFlags[i - 1] = parts[i];
-    }
-    this.knownFlags = knownFlags;
-  }
-
-  private void parseParamsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.consensusParams = ParseHelper.parseKeyValueIntegerPairs(line,
-        parts, 1, "=");
-  }
-
-  private void parseBandwidthWeightsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.bandwidthWeights = ParseHelper.parseKeyValueIntegerPairs(line,
-        parts, 1, "=");
-  }
-
-  private String consensusDigest;
-  @Override
-  public String getConsensusDigest() {
-    return this.consensusDigest;
-  }
-
-  private int networkStatusVersion;
-  @Override
-  public int getNetworkStatusVersion() {
-    return this.networkStatusVersion;
-  }
-
-  private String consensusFlavor;
-  @Override
-  public String getConsensusFlavor() {
-    return this.consensusFlavor;
-  }
-
-  private int consensusMethod;
-  @Override
-  public int getConsensusMethod() {
-    return this.consensusMethod;
-  }
-
-  private long validAfterMillis;
-  @Override
-  public long getValidAfterMillis() {
-    return this.validAfterMillis;
-  }
-
-  private long freshUntilMillis;
-  @Override
-  public long getFreshUntilMillis() {
-    return this.freshUntilMillis;
-  }
-
-  private long validUntilMillis;
-  @Override
-  public long getValidUntilMillis() {
-    return this.validUntilMillis;
-  }
-
-  private long voteSeconds;
-  @Override
-  public long getVoteSeconds() {
-    return this.voteSeconds;
-  }
-
-  private long distSeconds;
-  @Override
-  public long getDistSeconds() {
-    return this.distSeconds;
-  }
-
-  private String[] recommendedClientVersions;
-  @Override
-  public List<String> getRecommendedClientVersions() {
-    return this.recommendedClientVersions == null ? null :
-        Arrays.asList(this.recommendedClientVersions);
-  }
-
-  private String[] recommendedServerVersions;
-  @Override
-  public List<String> getRecommendedServerVersions() {
-    return this.recommendedServerVersions == null ? null :
-        Arrays.asList(this.recommendedServerVersions);
-  }
-
-  private List<String> packageLines;
-  @Override
-  public List<String> getPackageLines() {
-    return this.packageLines == null ? null
-        : new ArrayList<>(this.packageLines);
-  }
-
-  private String[] knownFlags;
-  @Override
-  public SortedSet<String> getKnownFlags() {
-    return new TreeSet<>(Arrays.asList(this.knownFlags));
-  }
-
-  private SortedMap<String, Integer> consensusParams;
-  @Override
-  public SortedMap<String, Integer> getConsensusParams() {
-    return this.consensusParams == null ? null:
-        new TreeMap<>(this.consensusParams);
-  }
-
-  private SortedMap<String, Integer> bandwidthWeights;
-  @Override
-  public SortedMap<String, Integer> getBandwidthWeights() {
-    return this.bandwidthWeights == null ? null :
-        new TreeMap<>(this.bandwidthWeights);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RelayNetworkStatusImpl.java b/src/org/torproject/descriptor/impl/RelayNetworkStatusImpl.java
deleted file mode 100644
index a5469db..0000000
--- a/src/org/torproject/descriptor/impl/RelayNetworkStatusImpl.java
+++ /dev/null
@@ -1,384 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.RelayNetworkStatus;
-
-/* TODO Write unit tests. */
-
-public class RelayNetworkStatusImpl extends NetworkStatusImpl
-    implements RelayNetworkStatus {
-
-  protected static List<RelayNetworkStatus> parseStatuses(
-      byte[] statusesBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<RelayNetworkStatus> parsedStatuses = new ArrayList<>();
-    List<byte[]> splitStatusBytes =
-        DescriptorImpl.splitRawDescriptorBytes(statusesBytes,
-        "network-status-version 2");
-    for (byte[] statusBytes : splitStatusBytes) {
-      RelayNetworkStatus parsedStatus = new RelayNetworkStatusImpl(
-          statusBytes, failUnrecognizedDescriptorLines);
-      parsedStatuses.add(parsedStatus);
-    }
-    return parsedStatuses;
-  }
-
-  protected RelayNetworkStatusImpl(byte[] statusBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(statusBytes, failUnrecognizedDescriptorLines, false, true);
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
-        "network-status-version,dir-source,fingerprint,contact,"
-        + "dir-signing-key,published").split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList(
-        "dir-options,client-versions,server-versions".split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkFirstKeyword("network-status-version");
-    this.clearParsedKeywords();
-    this.calculateDigest();
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "network-status-version ";
-      String sigToken = "\ndirectory-signature ";
-      if (!ascii.contains(sigToken)) {
-        return;
-      }
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      sig = ascii.indexOf("\n", sig) + 1;
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, sig - start);
-        this.statusDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-1").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.statusDigest == null) {
-      throw new DescriptorParseException("Could not calculate status "
-          + "digest.");
-    }
-  }
-
-  protected void parseHeader(byte[] headerBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
-    String nextCrypto = "";
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.isEmpty()) {
-        continue;
-      }
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "network-status-version":
-        this.parseNetworkStatusVersionLine(line, parts);
-        break;
-      case "dir-source":
-        this.parseDirSourceLine(line, parts);
-        break;
-      case "fingerprint":
-        this.parseFingerprintLine(line, parts);
-        break;
-      case "contact":
-        this.parseContactLine(line, parts);
-        break;
-      case "dir-signing-key":
-        this.parseDirSigningKeyLine(line, parts);
-        nextCrypto = "dir-signing-key";
-        break;
-      case "client-versions":
-        this.parseClientVersionsLine(line, parts);
-        break;
-      case "server-versions":
-        this.parseServerVersionsLine(line, parts);
-        break;
-      case "published":
-        this.parsePublishedLine(line, parts);
-        break;
-      case "dir-options":
-        this.parseDirOptionsLine(line, parts);
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        if (nextCrypto.equals("dir-signing-key")) {
-          this.dirSigningKey = cryptoString;
-        } else {
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in v2 network status.");
-        }
-        nextCrypto = "";
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in v2 network status.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  protected void parseFooter(byte[] footerBytes)
-      throws DescriptorParseException {
-    throw new DescriptorParseException("No directory footer expected in "
-        + "v2 network status.");
-  }
-
-  protected void parseDirectorySignature(byte[] directorySignatureBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(directorySignatureBytes)).
-        useDelimiter("\n");
-    String nextCrypto = "";
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "directory-signature":
-        this.parseDirectorySignatureLine(line, parts);
-        nextCrypto = "directory-signature";
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        if (nextCrypto.equals("directory-signature")) {
-          this.directorySignature = cryptoString;
-        } else {
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in v2 network status.");
-        }
-        nextCrypto = "";
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '" + line
-              + "' in v2 network status.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private void parseNetworkStatusVersionLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("network-status-version 2")) {
-      throw new DescriptorParseException("Illegal network status version "
-          + "number in line '" + line + "'.");
-    }
-    this.networkStatusVersion = 2;
-  }
-
-  private void parseDirSourceLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 4) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in v2 network status.");
-    }
-    if (parts[1].length() < 1) {
-      throw new DescriptorParseException("Illegal hostname in '" + line
-          + "'.");
-    }
-    this.address = ParseHelper.parseIpv4Address(line, parts[2]);
-    this.dirPort = ParseHelper.parsePort(line, parts[3]);
-  }
-
-
-  private void parseFingerprintLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in v2 network status.");
-    }
-    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
-        parts[1]);
-  }
-
-  private void parseContactLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (line.length() > "contact ".length()) {
-      this.contactLine = line.substring("contact ".length());
-    } else {
-      this.contactLine = "";
-    }
-  }
-
-  private void parseDirSigningKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-signing-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseClientVersionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.recommendedClientVersions = this.parseClientOrServerVersions(
-        line, parts);
-  }
-
-  private void parseServerVersionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.recommendedServerVersions = this.parseClientOrServerVersions(
-        line, parts);
-  }
-
-  private void parsePublishedLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseDirOptionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    String[] dirOptions = new String[parts.length - 1];
-    for (int i = 1; i < parts.length; i++) {
-      dirOptions[i - 1] = parts[i];
-    }
-    this.dirOptions = dirOptions;
-  }
-
-  private void parseDirectorySignatureLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.nickname = ParseHelper.parseNickname(line, parts[1]);
-  }
-
-  private String statusDigest;
-  @Override
-  public String getStatusDigest() {
-    return this.statusDigest;
-  }
-
-  private int networkStatusVersion;
-  @Override
-  public int getNetworkStatusVersion() {
-    return this.networkStatusVersion;
-  }
-
-  private String hostname;
-  @Override
-  public String getHostname() {
-    return this.hostname;
-  }
-
-  private String address;
-  @Override
-  public String getAddress() {
-    return this.address;
-  }
-
-  private int dirPort;
-  @Override
-  public int getDirport() {
-    return this.dirPort;
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private String contactLine;
-  @Override
-  public String getContactLine() {
-    return this.contactLine;
-  }
-
-  private String dirSigningKey;
-  @Override
-  public String getDirSigningKey() {
-    return this.dirSigningKey;
-  }
-
-  private String[] recommendedClientVersions;
-  @Override
-  public List<String> getRecommendedClientVersions() {
-    return this.recommendedClientVersions == null ? null :
-        Arrays.asList(this.recommendedClientVersions);
-  }
-
-  private String[] recommendedServerVersions;
-  @Override
-  public List<String> getRecommendedServerVersions() {
-    return this.recommendedServerVersions == null ? null :
-        Arrays.asList(this.recommendedServerVersions);
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private String[] dirOptions;
-  @Override
-  public SortedSet<String> getDirOptions() {
-    return new TreeSet<>(Arrays.asList(this.dirOptions));
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String directorySignature;
-  @Override
-  public String getDirectorySignature() {
-    return this.directorySignature;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RelayNetworkStatusVoteImpl.java b/src/org/torproject/descriptor/impl/RelayNetworkStatusVoteImpl.java
deleted file mode 100644
index 384ad1f..0000000
--- a/src/org/torproject/descriptor/impl/RelayNetworkStatusVoteImpl.java
+++ /dev/null
@@ -1,761 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.DirectorySignature;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TreeMap;
-import java.util.TreeSet;
-
-import org.torproject.descriptor.RelayNetworkStatusVote;
-
-/* Contains a network status vote. */
-public class RelayNetworkStatusVoteImpl extends NetworkStatusImpl
-    implements RelayNetworkStatusVote {
-
-  protected static List<RelayNetworkStatusVote> parseVotes(
-      byte[] votesBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<RelayNetworkStatusVote> parsedVotes = new ArrayList<>();
-    List<byte[]> splitVotesBytes =
-        DescriptorImpl.splitRawDescriptorBytes(votesBytes,
-        "network-status-version 3");
-    for (byte[] voteBytes : splitVotesBytes) {
-      RelayNetworkStatusVote parsedVote =
-          new RelayNetworkStatusVoteImpl(voteBytes,
-              failUnrecognizedDescriptorLines);
-      parsedVotes.add(parsedVote);
-    }
-    return parsedVotes;
-  }
-
-  protected RelayNetworkStatusVoteImpl(byte[] voteBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(voteBytes, failUnrecognizedDescriptorLines, false, false);
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList((
-        "vote-status,published,valid-after,fresh-until,"
-        + "valid-until,voting-delay,known-flags,dir-source,"
-        + "dir-key-certificate-version,fingerprint,dir-key-published,"
-        + "dir-key-expires,dir-identity-key,dir-signing-key,"
-        + "dir-key-certification").split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
-        "consensus-methods,client-versions,server-versions,"
-        + "flag-thresholds,params,contact,"
-        + "legacy-key,dir-key-crosscert,dir-address,directory-footer").
-        split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    Set<String> atLeastOnceKeywords = new HashSet<>(Arrays.asList(
-        "directory-signature"));
-    this.checkAtLeastOnceKeywords(atLeastOnceKeywords);
-    this.checkFirstKeyword("network-status-version");
-    this.clearParsedKeywords();
-  }
-
-  protected void parseHeader(byte[] headerBytes)
-      throws DescriptorParseException {
-    /* Initialize flag-thresholds values here for the case that the vote
-     * doesn't contain those values.  Initializing them in the constructor
-     * or when declaring variables wouldn't work, because those parts are
-     * evaluated later and would overwrite everything we parse here. */
-    this.stableUptime = -1L;
-    this.stableMtbf = -1L;
-    this.fastBandwidth = -1L;
-    this.guardWfu = -1.0;
-    this.guardTk = -1L;
-    this.guardBandwidthIncludingExits = -1L;
-    this.guardBandwidthExcludingExits = -1L;
-    this.enoughMtbfInfo = -1;
-    this.ignoringAdvertisedBws = -1;
-
-    Scanner s = new Scanner(new String(headerBytes)).useDelimiter("\n");
-    String nextCrypto = "";
-    StringBuilder crypto = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      String[] parts = line.split("[ \t]+");
-      String keyword = parts[0];
-      switch (keyword) {
-      case "network-status-version":
-        this.parseNetworkStatusVersionLine(line, parts);
-        break;
-      case "vote-status":
-        this.parseVoteStatusLine(line, parts);
-        break;
-      case "consensus-methods":
-        this.parseConsensusMethodsLine(line, parts);
-        break;
-      case "published":
-        this.parsePublishedLine(line, parts);
-        break;
-      case "valid-after":
-        this.parseValidAfterLine(line, parts);
-        break;
-      case "fresh-until":
-        this.parseFreshUntilLine(line, parts);
-        break;
-      case "valid-until":
-        this.parseValidUntilLine(line, parts);
-        break;
-      case "voting-delay":
-        this.parseVotingDelayLine(line, parts);
-        break;
-      case "client-versions":
-        this.parseClientVersionsLine(line, parts);
-        break;
-      case "server-versions":
-        this.parseServerVersionsLine(line, parts);
-        break;
-      case "package":
-        this.parsePackageLine(line, parts);
-        break;
-      case "known-flags":
-        this.parseKnownFlagsLine(line, parts);
-        break;
-      case "flag-thresholds":
-        this.parseFlagThresholdsLine(line, parts);
-        break;
-      case "params":
-        this.parseParamsLine(line, parts);
-        break;
-      case "dir-source":
-        this.parseDirSourceLine(line, parts);
-        break;
-      case "contact":
-        this.parseContactLine(line, parts);
-        break;
-      case "dir-key-certificate-version":
-        this.parseDirKeyCertificateVersionLine(line, parts);
-        break;
-      case "dir-address":
-        this.parseDirAddressLine(line, parts);
-        break;
-      case "fingerprint":
-        this.parseFingerprintLine(line, parts);
-        break;
-      case "legacy-dir-key":
-        this.parseLegacyDirKeyLine(line, parts);
-        break;
-      case "dir-key-published":
-        this.parseDirKeyPublished(line, parts);
-        break;
-      case "dir-key-expires":
-        this.parseDirKeyExpiresLine(line, parts);
-        break;
-      case "dir-identity-key":
-        this.parseDirIdentityKeyLine(line, parts);
-        nextCrypto = "dir-identity-key";
-        break;
-      case "dir-signing-key":
-        this.parseDirSigningKeyLine(line, parts);
-        nextCrypto = "dir-signing-key";
-        break;
-      case "dir-key-crosscert":
-        this.parseDirKeyCrosscertLine(line, parts);
-        nextCrypto = "dir-key-crosscert";
-        break;
-      case "dir-key-certification":
-        this.parseDirKeyCertificationLine(line, parts);
-        nextCrypto = "dir-key-certification";
-        break;
-      case "-----BEGIN":
-        crypto = new StringBuilder();
-        crypto.append(line).append("\n");
-        break;
-      case "-----END":
-        crypto.append(line).append("\n");
-        String cryptoString = crypto.toString();
-        crypto = null;
-        switch (nextCrypto) {
-        case "dir-identity-key":
-          this.dirIdentityKey = cryptoString;
-          break;
-        case "dir-signing-key":
-          this.dirSigningKey = cryptoString;
-          break;
-        case "dir-key-crosscert":
-          this.dirKeyCrosscert = cryptoString;
-          break;
-        case "dir-key-certification":
-          this.dirKeyCertification = cryptoString;
-          break;
-        default:
-          throw new DescriptorParseException("Unrecognized crypto "
-              + "block in vote.");
-        }
-        nextCrypto = "";
-        break;
-      default:
-        if (crypto != null) {
-          crypto.append(line).append("\n");
-        } else {
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in vote.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  private void parseNetworkStatusVersionLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("network-status-version 3")) {
-      throw new DescriptorParseException("Illegal network status version "
-          + "number in line '" + line + "'.");
-    }
-    this.networkStatusVersion = 3;
-  }
-
-  private void parseVoteStatusLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2 || !parts[1].equals("vote")) {
-      throw new DescriptorParseException("Line '" + line + "' indicates "
-          + "that this is not a vote.");
-    }
-  }
-
-  private void parseConsensusMethodsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in vote.");
-    }
-    Integer[] consensusMethods = new Integer[parts.length - 1];
-    for (int i = 1; i < parts.length; i++) {
-      int consensusMethod = -1;
-      try {
-        consensusMethod = Integer.parseInt(parts[i]);
-      } catch (NumberFormatException e) {
-        /* We'll notice below that consensusMethod is still -1. */
-      }
-      if (consensusMethod < 1) {
-        throw new DescriptorParseException("Illegal consensus method "
-            + "number in line '" + line + "'.");
-      }
-      consensusMethods[i - 1] = consensusMethod;
-    }
-    this.consensusMethods = consensusMethods;
-  }
-
-  private void parsePublishedLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseValidAfterLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.validAfterMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseFreshUntilLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.freshUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseValidUntilLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.validUntilMillis = ParseHelper.parseTimestampAtIndex(line, parts,
-        1, 2);
-  }
-
-  private void parseVotingDelayLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 3) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    try {
-      this.voteSeconds = Long.parseLong(parts[1]);
-      this.distSeconds = Long.parseLong(parts[2]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal values in line '" + line
-          + "'.");
-    }
-  }
-
-  private void parseClientVersionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.recommendedClientVersions = this.parseClientOrServerVersions(
-        line, parts);
-  }
-
-  private void parseServerVersionsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.recommendedServerVersions = this.parseClientOrServerVersions(
-        line, parts);
-  }
-
-  private void parsePackageLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 5) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    if (this.packageLines == null) {
-      this.packageLines = new ArrayList<>();
-    }
-    this.packageLines.add(line.substring("package ".length()));
-  }
-
-  private void parseKnownFlagsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 2) {
-      throw new DescriptorParseException("No known flags in line '" + line
-          + "'.");
-    }
-    String[] knownFlags = new String[parts.length - 1];
-    for (int i = 1; i < parts.length; i++) {
-      knownFlags[i - 1] = parts[i];
-    }
-    this.knownFlags = knownFlags;
-  }
-
-  private void parseFlagThresholdsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length < 2) {
-      throw new DescriptorParseException("No flag thresholds in line '"
-          + line + "'.");
-    }
-    SortedMap<String, String> flagThresholds =
-        ParseHelper.parseKeyValueStringPairs(line, parts, 1, "=");
-    try {
-      for (Map.Entry<String, String> e : flagThresholds.entrySet()) {
-        switch (e.getKey()) {
-        case "stable-uptime":
-          this.stableUptime = Long.parseLong(e.getValue());
-          break;
-        case "stable-mtbf":
-          this.stableMtbf = Long.parseLong(e.getValue());
-          break;
-        case "fast-speed":
-          this.fastBandwidth = Long.parseLong(e.getValue());
-          break;
-        case "guard-wfu":
-          this.guardWfu = Double.parseDouble(e.getValue().
-              replaceAll("%", ""));
-          break;
-        case "guard-tk":
-          this.guardTk = Long.parseLong(e.getValue());
-          break;
-        case "guard-bw-inc-exits":
-          this.guardBandwidthIncludingExits =
-              Long.parseLong(e.getValue());
-          break;
-        case "guard-bw-exc-exits":
-          this.guardBandwidthExcludingExits =
-              Long.parseLong(e.getValue());
-          break;
-        case "enough-mtbf":
-          this.enoughMtbfInfo = Integer.parseInt(e.getValue());
-          break;
-        case "ignoring-advertised-bws":
-          this.ignoringAdvertisedBws = Integer.parseInt(e.getValue());
-          break;
-        default:
-          // empty
-        }
-      }
-    } catch (NumberFormatException ex) {
-      throw new DescriptorParseException("Illegal value in line '"
-          + line + "'.");
-    }
-  }
-
-  private void parseParamsLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.consensusParams = ParseHelper.parseKeyValueIntegerPairs(line,
-        parts, 1, "=");
-  }
-
-  private void parseDirSourceLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 7) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in vote.");
-    }
-    this.nickname = ParseHelper.parseNickname(line, parts[1]);
-    this.identity = ParseHelper.parseTwentyByteHexString(line, parts[2]);
-    if (parts[3].length() < 1) {
-      throw new DescriptorParseException("Illegal hostname in '" + line
-          + "'.");
-    }
-    this.hostname = parts[3];
-    this.address = ParseHelper.parseIpv4Address(line, parts[4]);
-    this.dirPort = ParseHelper.parsePort(line, parts[5]);
-    this.orPort = ParseHelper.parsePort(line, parts[6]);
-  }
-
-  private void parseContactLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (line.length() > "contact ".length()) {
-      this.contactLine = line.substring("contact ".length());
-    } else {
-      this.contactLine = "";
-    }
-  }
-
-  private void parseDirKeyCertificateVersionLine(String line,
-      String[] parts) throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in vote.");
-    }
-    try {
-      this.dirKeyCertificateVersion = Integer.parseInt(parts[1]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal dir key certificate "
-          + "version in line '" + line + "'.");
-    }
-    if (this.dirKeyCertificateVersion < 1) {
-      throw new DescriptorParseException("Illegal dir key certificate "
-          + "version in line '" + line + "'.");
-    }
-  }
-
-  private void parseDirAddressLine(String line, String[] parts) {
-    /* Nothing new to learn here.  Also, this line hasn't been observed
-     * "in the wild" yet.  Maybe it's just an urban legend. */
-  }
-
-  private void parseFingerprintLine(String line, String[] parts)
-      throws DescriptorParseException {
-    /* Nothing new to learn here.  We already know the fingerprint from
-     * the dir-source line.  But we should at least check that there's a
-     * valid fingerprint in this line. */
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in vote.");
-    }
-    ParseHelper.parseTwentyByteHexString(line, parts[1]);
-  }
-
-  private void parseLegacyDirKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (parts.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.legacyDirKey = ParseHelper.parseTwentyByteHexString(line, parts[1]);
-  }
-
-  private void parseDirKeyPublished(String line, String[] parts)
-      throws DescriptorParseException {
-    this.dirKeyPublishedMillis = ParseHelper.parseTimestampAtIndex(line,
-        parts, 1, 2);
-  }
-
-  private void parseDirKeyExpiresLine(String line, String[] parts)
-      throws DescriptorParseException {
-    this.dirKeyExpiresMillis = ParseHelper.parseTimestampAtIndex(line,
-        parts, 1, 2);
-  }
-
-  private void parseDirIdentityKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-identity-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseDirSigningKeyLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-signing-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseDirKeyCrosscertLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-key-crosscert")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseDirKeyCertificationLine(String line, String[] parts)
-      throws DescriptorParseException {
-    if (!line.equals("dir-key-certification")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  protected void parseFooter(byte[] footerBytes)
-      throws DescriptorParseException {
-    Scanner s = new Scanner(new String(footerBytes)).useDelimiter("\n");
-    while (s.hasNext()) {
-      String line = s.next();
-      if (!line.equals("directory-footer")) {
-        if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized line '"
-              + line + "' in vote.");
-        } else {
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          this.unrecognizedLines.add(line);
-        }
-      }
-    }
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String identity;
-  @Override
-  public String getIdentity() {
-    return this.identity;
-  }
-
-  private String hostname;
-  @Override
-  public String getHostname() {
-    return this.hostname;
-  }
-
-  private String address;
-  @Override
-  public String getAddress() {
-    return this.address;
-  }
-
-  private int dirPort;
-  @Override
-  public int getDirport() {
-    return this.dirPort;
-  }
-
-  private int orPort;
-  @Override
-  public int getOrport() {
-    return this.orPort;
-  }
-
-  private String contactLine;
-  @Override
-  public String getContactLine() {
-    return this.contactLine;
-  }
-
-  private int dirKeyCertificateVersion;
-  @Override
-  public int getDirKeyCertificateVersion() {
-    return this.dirKeyCertificateVersion;
-  }
-
-  private String legacyDirKey;
-  @Override
-  public String getLegacyDirKey() {
-    return this.legacyDirKey;
-  }
-
-  private long dirKeyPublishedMillis;
-  @Override
-  public long getDirKeyPublishedMillis() {
-    return this.dirKeyPublishedMillis;
-  }
-
-  private long dirKeyExpiresMillis;
-  @Override
-  public long getDirKeyExpiresMillis() {
-    return this.dirKeyExpiresMillis;
-  }
-
-  private String dirIdentityKey;
-  @Override
-  public String getDirIdentityKey() {
-    return this.dirIdentityKey;
-  }
-
-  private String dirSigningKey;
-  @Override
-  public String getDirSigningKey() {
-    return this.dirSigningKey;
-  }
-
-  private String dirKeyCrosscert;
-  @Override
-  public String getDirKeyCrosscert() {
-    return this.dirKeyCrosscert;
-  }
-
-  private String dirKeyCertification;
-  @Override
-  public String getDirKeyCertification() {
-    return this.dirKeyCertification;
-  }
-
-  @Override
-  public String getSigningKeyDigest() {
-    String signingKeyDigest = null;
-    if (this.signatures != null && !this.signatures.isEmpty()) {
-      for (DirectorySignature signature : this.signatures) {
-        if (DirectorySignatureImpl.DEFAULT_ALGORITHM.equals(
-            signature.getAlgorithm())) {
-          signingKeyDigest = signature.getSigningKeyDigest();
-          break;
-        }
-      }
-    }
-    return signingKeyDigest;
-  }
-
-  private int networkStatusVersion;
-  @Override
-  public int getNetworkStatusVersion() {
-    return this.networkStatusVersion;
-  }
-
-  private Integer[] consensusMethods;
-  @Override
-  public List<Integer> getConsensusMethods() {
-    return this.consensusMethods == null ? null :
-        Arrays.asList(this.consensusMethods);
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private long validAfterMillis;
-  @Override
-  public long getValidAfterMillis() {
-    return this.validAfterMillis;
-  }
-
-  private long freshUntilMillis;
-  @Override
-  public long getFreshUntilMillis() {
-    return this.freshUntilMillis;
-  }
-
-  private long validUntilMillis;
-  @Override
-  public long getValidUntilMillis() {
-    return this.validUntilMillis;
-  }
-
-  private long voteSeconds;
-  @Override
-  public long getVoteSeconds() {
-    return this.voteSeconds;
-  }
-
-  private long distSeconds;
-  @Override
-  public long getDistSeconds() {
-    return this.distSeconds;
-  }
-
-  private String[] recommendedClientVersions;
-  @Override
-  public List<String> getRecommendedClientVersions() {
-    return this.recommendedClientVersions == null ? null :
-        Arrays.asList(this.recommendedClientVersions);
-  }
-
-  private String[] recommendedServerVersions;
-  @Override
-  public List<String> getRecommendedServerVersions() {
-    return this.recommendedServerVersions == null ? null :
-        Arrays.asList(this.recommendedServerVersions);
-  }
-
-  private List<String> packageLines;
-  @Override
-  public List<String> getPackageLines() {
-    return this.packageLines == null ? null
-        : new ArrayList<>(this.packageLines);
-  }
-
-  private String[] knownFlags;
-  @Override
-  public SortedSet<String> getKnownFlags() {
-    return new TreeSet<>(Arrays.asList(this.knownFlags));
-  }
-
-  private long stableUptime;
-  @Override
-  public long getStableUptime() {
-    return this.stableUptime;
-  }
-
-  private long stableMtbf;
-  @Override
-  public long getStableMtbf() {
-    return this.stableMtbf;
-  }
-
-  private long fastBandwidth;
-  @Override
-  public long getFastBandwidth() {
-    return this.fastBandwidth;
-  }
-
-  private double guardWfu;
-  @Override
-  public double getGuardWfu() {
-    return this.guardWfu;
-  }
-
-  private long guardTk;
-  @Override
-  public long getGuardTk() {
-    return this.guardTk;
-  }
-
-  private long guardBandwidthIncludingExits;
-  @Override
-  public long getGuardBandwidthIncludingExits() {
-    return this.guardBandwidthIncludingExits;
-  }
-
-  private long guardBandwidthExcludingExits;
-  @Override
-  public long getGuardBandwidthExcludingExits() {
-    return this.guardBandwidthExcludingExits;
-  }
-
-  private int enoughMtbfInfo;
-  @Override
-  public int getEnoughMtbfInfo() {
-    return this.enoughMtbfInfo;
-  }
-
-  private int ignoringAdvertisedBws;
-  @Override
-  public int getIgnoringAdvertisedBws() {
-    return this.ignoringAdvertisedBws;
-  }
-
-  private SortedMap<String, Integer> consensusParams;
-  @Override
-  public SortedMap<String, Integer> getConsensusParams() {
-    return this.consensusParams == null ? null:
-        new TreeMap<>(this.consensusParams);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RelayServerDescriptorImpl.java b/src/org/torproject/descriptor/impl/RelayServerDescriptorImpl.java
deleted file mode 100644
index 4957072..0000000
--- a/src/org/torproject/descriptor/impl/RelayServerDescriptorImpl.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.RelayServerDescriptor;
-import org.torproject.descriptor.ServerDescriptor;
-
-public class RelayServerDescriptorImpl extends ServerDescriptorImpl
-    implements RelayServerDescriptor {
-
-  protected static List<ServerDescriptor> parseDescriptors(
-      byte[] descriptorsBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    List<ServerDescriptor> parsedDescriptors = new ArrayList<>();
-    List<byte[]> splitDescriptorsBytes =
-        DescriptorImpl.splitRawDescriptorBytes(descriptorsBytes,
-        "router ");
-    for (byte[] descriptorBytes : splitDescriptorsBytes) {
-      ServerDescriptor parsedDescriptor =
-          new RelayServerDescriptorImpl(descriptorBytes,
-          failUnrecognizedDescriptorLines);
-      parsedDescriptors.add(parsedDescriptor);
-    }
-    return parsedDescriptors;
-  }
-
-  protected RelayServerDescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines);
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/RouterStatusEntryImpl.java b/src/org/torproject/descriptor/impl/RouterStatusEntryImpl.java
deleted file mode 100644
index a359c50..0000000
--- a/src/org/torproject/descriptor/impl/RouterStatusEntryImpl.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.RouterStatusEntry;
-
-public class RouterStatusEntryImpl implements RouterStatusEntry {
-
-  protected RouterStatusEntryImpl(String fingerprint, String nickname,
-      boolean isLive, boolean isVerified) {
-    this.fingerprint = fingerprint;
-    this.nickname = nickname;
-    this.isLive = isLive;
-    this.isVerified = isVerified;
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private boolean isLive;
-  @Override
-  public boolean isLive() {
-    return this.isLive;
-  }
-
-  private boolean isVerified;
-  @Override
-  public boolean isVerified() {
-    return this.isVerified;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/ServerDescriptorImpl.java b/src/org/torproject/descriptor/impl/ServerDescriptorImpl.java
deleted file mode 100644
index 1805dca..0000000
--- a/src/org/torproject/descriptor/impl/ServerDescriptorImpl.java
+++ /dev/null
@@ -1,985 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import java.io.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-
-import javax.xml.bind.DatatypeConverter;
-
-import org.torproject.descriptor.BandwidthHistory;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ServerDescriptor;
-
-/* Contains a server descriptor. */
-public abstract class ServerDescriptorImpl extends DescriptorImpl
-    implements ServerDescriptor {
-
-  protected ServerDescriptorImpl(byte[] descriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(descriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.parseDescriptorBytes();
-    this.calculateDigest();
-    this.calculateDigestSha256();
-    Set<String> exactlyOnceKeywords = new HashSet<>(Arrays.asList(
-        "router,bandwidth,published".split(",")));
-    this.checkExactlyOnceKeywords(exactlyOnceKeywords);
-    Set<String> atMostOnceKeywords = new HashSet<>(Arrays.asList((
-        "identity-ed25519,master-key-ed25519,platform,fingerprint,"
-        + "hibernating,uptime,contact,family,read-history,write-history,"
-        + "eventdns,caches-extra-info,extra-info-digest,"
-        + "hidden-service-dir,protocols,allow-single-hop-exits,onion-key,"
-        + "signing-key,ipv6-policy,ntor-onion-key,onion-key-crosscert,"
-        + "ntor-onion-key-crosscert,tunnelled-dir-server,"
-        + "router-sig-ed25519,router-signature,router-digest-sha256,"
-        + "router-digest").split(",")));
-    this.checkAtMostOnceKeywords(atMostOnceKeywords);
-    this.checkFirstKeyword("router");
-    if (this.getKeywordCount("accept") == 0 &&
-        this.getKeywordCount("reject") == 0) {
-      throw new DescriptorParseException("Either keyword 'accept' or "
-          + "'reject' must be contained at least once.");
-    }
-    this.clearParsedKeywords();
-    return;
-  }
-
-  private void parseDescriptorBytes() throws DescriptorParseException {
-    Scanner s = new Scanner(new String(this.rawDescriptorBytes)).
-        useDelimiter("\n");
-    String nextCrypto = "";
-    List<String> cryptoLines = null;
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.startsWith("@")) {
-        continue;
-      }
-      String lineNoOpt = line.startsWith("opt ") ?
-          line.substring("opt ".length()) : line;
-      String[] partsNoOpt = lineNoOpt.split("[ \t]+");
-      String keyword = partsNoOpt[0];
-      switch (keyword) {
-      case "router":
-        this.parseRouterLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "or-address":
-        this.parseOrAddressLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "bandwidth":
-        this.parseBandwidthLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "platform":
-        this.parsePlatformLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "published":
-        this.parsePublishedLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "fingerprint":
-        this.parseFingerprintLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "hibernating":
-        this.parseHibernatingLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "uptime":
-        this.parseUptimeLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "onion-key":
-        this.parseOnionKeyLine(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "onion-key";
-        break;
-      case "signing-key":
-        this.parseSigningKeyLine(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "signing-key";
-        break;
-      case "accept":
-        this.parseAcceptLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "reject":
-        this.parseRejectLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-signature":
-        this.parseRouterSignatureLine(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "router-signature";
-        break;
-      case "contact":
-        this.parseContactLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "family":
-        this.parseFamilyLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "read-history":
-        this.parseReadHistoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "write-history":
-        this.parseWriteHistoryLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "eventdns":
-        this.parseEventdnsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "caches-extra-info":
-        this.parseCachesExtraInfoLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "extra-info-digest":
-        this.parseExtraInfoDigestLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "hidden-service-dir":
-        this.parseHiddenServiceDirLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "protocols":
-        this.parseProtocolsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "allow-single-hop-exits":
-        this.parseAllowSingleHopExitsLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "dircacheport":
-        this.parseDircacheportLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-digest":
-        this.parseRouterDigestLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-digest-sha256":
-        this.parseRouterDigestSha256Line(line, lineNoOpt, partsNoOpt);
-        break;
-      case "ipv6-policy":
-        this.parseIpv6PolicyLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "ntor-onion-key":
-        this.parseNtorOnionKeyLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "identity-ed25519":
-        this.parseIdentityEd25519Line(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "identity-ed25519";
-        break;
-      case "master-key-ed25519":
-        this.parseMasterKeyEd25519Line(line, lineNoOpt, partsNoOpt);
-        break;
-      case "router-sig-ed25519":
-        this.parseRouterSigEd25519Line(line, lineNoOpt, partsNoOpt);
-        break;
-      case "onion-key-crosscert":
-        this.parseOnionKeyCrosscert(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "onion-key-crosscert";
-        break;
-      case "ntor-onion-key-crosscert":
-        this.parseNtorOnionKeyCrosscert(line, lineNoOpt, partsNoOpt);
-        nextCrypto = "ntor-onion-key-crosscert";
-        break;
-      case "tunnelled-dir-server":
-        this.parseTunnelledDirServerLine(line, lineNoOpt, partsNoOpt);
-        break;
-      case "-----BEGIN":
-        cryptoLines = new ArrayList<>();
-        cryptoLines.add(line);
-        break;
-      case "-----END":
-        cryptoLines.add(line);
-        StringBuilder sb = new StringBuilder();
-        for (String cryptoLine : cryptoLines) {
-          sb.append("\n").append(cryptoLine);
-        }
-        String cryptoString = sb.toString().substring(1);
-        switch (nextCrypto) {
-        case "onion-key":
-          this.onionKey = cryptoString;
-          break;
-        case "signing-key":
-          this.signingKey = cryptoString;
-          break;
-        case "router-signature":
-          this.routerSignature = cryptoString;
-          break;
-        case "identity-ed25519":
-          this.identityEd25519 = cryptoString;
-          this.parseIdentityEd25519CryptoBlock(cryptoString);
-          break;
-        case "onion-key-crosscert":
-          this.onionKeyCrosscert = cryptoString;
-          break;
-        case "ntor-onion-key-crosscert":
-          this.ntorOnionKeyCrosscert = cryptoString;
-          break;
-        default:
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized crypto "
-                + "block '" + cryptoString + "' in server descriptor.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.addAll(cryptoLines);
-          }
-        }
-        cryptoLines = null;
-        nextCrypto = "";
-        break;
-      default:
-        if (cryptoLines != null) {
-          cryptoLines.add(line);
-        } else {
-          ParseHelper.parseKeyword(line, partsNoOpt[0]);
-          if (this.failUnrecognizedDescriptorLines) {
-            throw new DescriptorParseException("Unrecognized line '"
-                + line + "' in server descriptor.");
-          } else {
-            if (this.unrecognizedLines == null) {
-              this.unrecognizedLines = new ArrayList<>();
-            }
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-  }
-
-  private void parseRouterLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 6) {
-      throw new DescriptorParseException("Illegal line '" + line
-          + "' in server descriptor.");
-    }
-    this.nickname = ParseHelper.parseNickname(line, partsNoOpt[1]);
-    this.address = ParseHelper.parseIpv4Address(line, partsNoOpt[2]);
-    this.orPort = ParseHelper.parsePort(line, partsNoOpt[3]);
-    this.socksPort = ParseHelper.parsePort(line, partsNoOpt[4]);
-    this.dirPort = ParseHelper.parsePort(line, partsNoOpt[5]);
-  }
-
-  private void parseOrAddressLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    /* TODO Add more checks. */
-    /* TODO Add tests. */
-    this.orAddresses.add(partsNoOpt[1]);
-  }
-
-  private void parseBandwidthLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length < 3 || partsNoOpt.length > 4) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    boolean isValid = false;
-    try {
-      this.bandwidthRate = Integer.parseInt(partsNoOpt[1]);
-      this.bandwidthBurst = Integer.parseInt(partsNoOpt[2]);
-      if (partsNoOpt.length == 4) {
-        this.bandwidthObserved = Integer.parseInt(partsNoOpt[3]);
-      }
-      if (this.bandwidthRate >= 0 && this.bandwidthBurst >= 0 &&
-          this.bandwidthObserved >= 0) {
-        isValid = true;
-      }
-      if (partsNoOpt.length < 4) {
-        /* Tor versions 0.0.8 and older only wrote bandwidth lines with
-         * rate and burst values, but no observed value. */
-        this.bandwidthObserved = -1;
-      }
-    } catch (NumberFormatException e) {
-      /* Handle below. */
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("Illegal values in line '" + line
-          + "'.");
-    }
-  }
-
-  private void parsePlatformLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (lineNoOpt.length() > "platform ".length()) {
-      this.platform = lineNoOpt.substring("platform ".length());
-    } else {
-      this.platform = "";
-    }
-  }
-
-  private void parsePublishedLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.publishedMillis = ParseHelper.parseTimestampAtIndex(line,
-        partsNoOpt, 1, 2);
-  }
-
-  private void parseFingerprintLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (lineNoOpt.length() != "fingerprint".length() + 5 * 10) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.fingerprint = ParseHelper.parseTwentyByteHexString(line,
-        lineNoOpt.substring("fingerprint ".length()).replaceAll(" ", ""));
-  }
-
-  private void parseHibernatingLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.hibernating = ParseHelper.parseBoolean(partsNoOpt[1], line);
-  }
-
-  private void parseUptimeLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Wrong number of values in line "
-          + "'" + line + "'.");
-    }
-    boolean isValid = false;
-    try {
-      this.uptime = Long.parseLong(partsNoOpt[1]);
-      isValid = true;
-    } catch (NumberFormatException e) {
-      /* Handle below. */
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("Illegal value in line '" + line
-          + "'.");
-    }
-  }
-
-  private void parseOnionKeyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("onion-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseSigningKeyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("signing-key")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseAcceptLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.parseExitPolicyLine(line, lineNoOpt, partsNoOpt);
-  }
-
-  private void parseRejectLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.parseExitPolicyLine(line, lineNoOpt, partsNoOpt);
-  }
-
-  private void parseExitPolicyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    ParseHelper.parseExitPattern(line, partsNoOpt[1]);
-    this.exitPolicyLines.add(lineNoOpt);
-  }
-
-  private void parseRouterSignatureLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("router-signature")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseContactLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (lineNoOpt.length() > "contact ".length()) {
-      this.contact = lineNoOpt.substring("contact ".length());
-    } else {
-      this.contact = "";
-    }
-  }
-
-  private void parseFamilyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    String[] familyEntries = new String[partsNoOpt.length - 1];
-    for (int i = 1; i < partsNoOpt.length; i++) {
-      if (partsNoOpt[i].startsWith("$")) {
-        if (partsNoOpt[i].contains("=") ^ partsNoOpt[i].contains("~")) {
-          String separator = partsNoOpt[i].contains("=") ? "=" : "~";
-          String fingerprint = ParseHelper.parseTwentyByteHexString(line,
-              partsNoOpt[i].substring(1, partsNoOpt[i].indexOf(
-              separator)));
-          String nickname = ParseHelper.parseNickname(line,
-              partsNoOpt[i].substring(partsNoOpt[i].indexOf(
-              separator) + 1));
-          familyEntries[i - 1] = "$" + fingerprint + separator + nickname;
-        } else {
-          familyEntries[i - 1] = "$"
-              + ParseHelper.parseTwentyByteHexString(line,
-              partsNoOpt[i].substring(1));
-        }
-      } else {
-        familyEntries[i - 1] = ParseHelper.parseNickname(line,
-            partsNoOpt[i]);
-      }
-    }
-    this.familyEntries = familyEntries;
-  }
-
-  private void parseReadHistoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.readHistory = new BandwidthHistoryImpl(line, lineNoOpt,
-        partsNoOpt);
-  }
-
-  private void parseWriteHistoryLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    this.writeHistory = new BandwidthHistoryImpl(line, lineNoOpt,
-        partsNoOpt);
-  }
-
-  private void parseEventdnsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.usesEnhancedDnsLogic = ParseHelper.parseBoolean(partsNoOpt[1], line);
-  }
-
-  private void parseCachesExtraInfoLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("caches-extra-info")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.cachesExtraInfo = true;
-  }
-
-  private void parseExtraInfoDigestLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length < 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.extraInfoDigest = ParseHelper.parseTwentyByteHexString(line,
-        partsNoOpt[1]);
-    if (partsNoOpt.length >= 3) {
-      ParseHelper.parseThirtyTwoByteBase64String(line, partsNoOpt[2]);
-      this.extraInfoDigestSha256 = partsNoOpt[2];
-    }
-  }
-
-  private void parseHiddenServiceDirLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length == 1) {
-      this.hiddenServiceDirVersions = new Integer[] { 2 };
-    } else {
-      try {
-        Integer[] result = new Integer[partsNoOpt.length - 1];
-        for (int i = 1; i < partsNoOpt.length; i++) {
-          result[i - 1] = Integer.parseInt(partsNoOpt[i]);
-        }
-        this.hiddenServiceDirVersions = result;
-      } catch (NumberFormatException e) {
-        throw new DescriptorParseException("Illegal value in line '"
-            + line + "'.");
-      }
-    }
-  }
-
-  private void parseProtocolsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    int linkIndex = -1, circuitIndex = -1;
-    for (int i = 1; i < partsNoOpt.length; i++) {
-      switch (partsNoOpt[i]) {
-      case "Link":
-        linkIndex = i;
-        break;
-      case "Circuit":
-        circuitIndex = i;
-        break;
-      default:
-        // empty
-      }
-    }
-    if (linkIndex < 0 || circuitIndex < 0 || circuitIndex < linkIndex) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    try {
-      Integer[] linkProtocolVersions =
-          new Integer[circuitIndex - linkIndex - 1];
-      for (int i = linkIndex + 1, j = 0; i < circuitIndex; i++, j++) {
-        linkProtocolVersions[j] = Integer.parseInt(partsNoOpt[i]);
-      }
-      Integer[] circuitProtocolVersions =
-          new Integer[partsNoOpt.length - circuitIndex - 1];
-      for (int i = circuitIndex + 1, j = 0; i < partsNoOpt.length;
-          i++, j++) {
-        circuitProtocolVersions[j] = Integer.parseInt(partsNoOpt[i]);
-      }
-      this.linkProtocolVersions = linkProtocolVersions;
-      this.circuitProtocolVersions = circuitProtocolVersions;
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseAllowSingleHopExitsLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("allow-single-hop-exits")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.allowSingleHopExits = true;
-  }
-
-  private void parseDircacheportLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    /* The dircacheport line was only contained in server descriptors
-     * published by Tor 0.0.8 and before.  It's only specified in old
-     * tor-spec.txt versions. */
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    if (this.dirPort != 0) {
-      throw new DescriptorParseException("At most one of dircacheport "
-          + "and the directory port in the router line may be non-zero.");
-    }
-    this.dirPort = ParseHelper.parsePort(line, partsNoOpt[1]);
-  }
-
-  private void parseRouterDigestLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.serverDescriptorDigest = ParseHelper.parseTwentyByteHexString(
-        line, partsNoOpt[1]);
-  }
-
-  private void parseIpv6PolicyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    boolean isValid = true;
-    if (partsNoOpt.length != 3) {
-      isValid = false;
-    } else {
-        switch (partsNoOpt[1]) {
-        case "accept":
-        case "reject":
-          this.ipv6DefaultPolicy = partsNoOpt[1];
-          this.ipv6PortList = partsNoOpt[2];
-          String[] ports = partsNoOpt[2].split(",", -1);
-          for (int i = 0; i < ports.length; i++) {
-            if (ports[i].length() < 1) {
-              isValid = false;
-              break;
-            }
-          }
-          break;
-        default:
-          isValid = false;
-        }
-    }
-    if (!isValid) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseNtorOnionKeyLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.ntorOnionKey = partsNoOpt[1].replaceAll("=", "");
-  }
-
-  private void parseIdentityEd25519Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 1) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseOnionKeyCrosscert(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 1) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseNtorOnionKeyCrosscert(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    try {
-      this.ntorOnionKeyCrosscertSign = Integer.parseInt(partsNoOpt[1]);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-  }
-
-  private void parseTunnelledDirServerLine(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (!lineNoOpt.equals("tunnelled-dir-server")) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.tunnelledDirServer = true;
-  }
-
-  private void parseIdentityEd25519CryptoBlock(String cryptoString)
-      throws DescriptorParseException {
-    String masterKeyEd25519FromIdentityEd25519 =
-        ParseHelper.parseMasterKeyEd25519FromIdentityEd25519CryptoBlock(
-        cryptoString);
-    if (this.masterKeyEd25519 != null && !this.masterKeyEd25519.equals(
-        masterKeyEd25519FromIdentityEd25519)) {
-      throw new DescriptorParseException("Mismatch between "
-          + "identity-ed25519 and master-key-ed25519.");
-    }
-    this.masterKeyEd25519 = masterKeyEd25519FromIdentityEd25519;
-  }
-
-  private void parseMasterKeyEd25519Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    String masterKeyEd25519FromMasterKeyEd25519Line = partsNoOpt[1];
-    if (this.masterKeyEd25519 != null && !masterKeyEd25519.equals(
-        masterKeyEd25519FromMasterKeyEd25519Line)) {
-      throw new DescriptorParseException("Mismatch between "
-          + "identity-ed25519 and master-key-ed25519.");
-    }
-    this.masterKeyEd25519 = masterKeyEd25519FromMasterKeyEd25519Line;
-  }
-
-  private void parseRouterSigEd25519Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    this.routerSignatureEd25519 = partsNoOpt[1];
-  }
-
-  private void parseRouterDigestSha256Line(String line, String lineNoOpt,
-      String[] partsNoOpt) throws DescriptorParseException {
-    if (partsNoOpt.length != 2) {
-      throw new DescriptorParseException("Illegal line '" + line + "'.");
-    }
-    ParseHelper.parseThirtyTwoByteBase64String(line, partsNoOpt[1]);
-    this.serverDescriptorDigestSha256 = partsNoOpt[1];
-  }
-
-  private void calculateDigest() throws DescriptorParseException {
-    if (this.serverDescriptorDigest != null) {
-      /* We already learned the descriptor digest of this bridge
-       * descriptor from a "router-digest" line. */
-      return;
-    }
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "router ";
-      String sigToken = "\nrouter-signature\n";
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start,
-            forDigest, 0, sig - start);
-        this.serverDescriptorDigest = DatatypeConverter.printHexBinary(
-            MessageDigest.getInstance("SHA-1").digest(forDigest)).
-            toLowerCase();
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.serverDescriptorDigest == null) {
-      throw new DescriptorParseException("Could not calculate server "
-          + "descriptor digest.");
-    }
-  }
-
-  private void calculateDigestSha256() throws DescriptorParseException {
-    if (this.serverDescriptorDigestSha256 != null) {
-      /* We already learned the descriptor digest of this bridge
-       * descriptor from a "router-digest-sha256" line. */
-      return;
-    }
-    try {
-      String ascii = new String(this.getRawDescriptorBytes(), "US-ASCII");
-      String startToken = "router ";
-      String sigToken = "\n-----END SIGNATURE-----\n";
-      int start = ascii.indexOf(startToken);
-      int sig = ascii.indexOf(sigToken) + sigToken.length();
-      if (start >= 0 && sig >= 0 && sig > start) {
-        byte[] forDigest = new byte[sig - start];
-        System.arraycopy(this.getRawDescriptorBytes(), start, forDigest,
-            0, sig - start);
-        this.serverDescriptorDigestSha256 =
-            DatatypeConverter.printBase64Binary(
-            MessageDigest.getInstance("SHA-256").digest(forDigest)).
-            replaceAll("=", "");
-      }
-    } catch (UnsupportedEncodingException e) {
-      /* Handle below. */
-    } catch (NoSuchAlgorithmException e) {
-      /* Handle below. */
-    }
-    if (this.serverDescriptorDigestSha256 == null) {
-      throw new DescriptorParseException("Could not calculate server "
-          + "descriptor SHA-256 digest.");
-    }
-  }
-
-  private String serverDescriptorDigest;
-  @Override
-  public String getServerDescriptorDigest() {
-    return this.serverDescriptorDigest;
-  }
-
-  private String serverDescriptorDigestSha256;
-  @Override
-  public String getServerDescriptorDigestSha256() {
-    return this.serverDescriptorDigestSha256;
-  }
-
-  private String nickname;
-  @Override
-  public String getNickname() {
-    return this.nickname;
-  }
-
-  private String address;
-  @Override
-  public String getAddress() {
-    return this.address;
-  }
-
-  private int orPort;
-  @Override
-  public int getOrPort() {
-    return this.orPort;
-  }
-
-  private int socksPort;
-  @Override
-  public int getSocksPort() {
-    return this.socksPort;
-  }
-
-  private int dirPort;
-  @Override
-  public int getDirPort() {
-    return this.dirPort;
-  }
-
-  private List<String> orAddresses = new ArrayList<>();
-  @Override
-  public List<String> getOrAddresses() {
-    return new ArrayList<>(this.orAddresses);
-  }
-
-  private int bandwidthRate;
-  @Override
-  public int getBandwidthRate() {
-    return this.bandwidthRate;
-  }
-
-  private int bandwidthBurst;
-  @Override
-  public int getBandwidthBurst() {
-    return this.bandwidthBurst;
-  }
-
-  private int bandwidthObserved;
-  @Override
-  public int getBandwidthObserved() {
-    return this.bandwidthObserved;
-  }
-
-  private String platform;
-  @Override
-  public String getPlatform() {
-    return this.platform;
-  }
-
-  private long publishedMillis;
-  @Override
-  public long getPublishedMillis() {
-    return this.publishedMillis;
-  }
-
-  private String fingerprint;
-  @Override
-  public String getFingerprint() {
-    return this.fingerprint;
-  }
-
-  private boolean hibernating;
-  @Override
-  public boolean isHibernating() {
-    return this.hibernating;
-  }
-
-  private Long uptime;
-  @Override
-  public Long getUptime() {
-    return this.uptime;
-  }
-
-  private String onionKey;
-  @Override
-  public String getOnionKey() {
-    return this.onionKey;
-  }
-
-  private String signingKey;
-  @Override
-  public String getSigningKey() {
-    return this.signingKey;
-  }
-
-  private List<String> exitPolicyLines = new ArrayList<>();
-  @Override
-  public List<String> getExitPolicyLines() {
-    return new ArrayList<>(this.exitPolicyLines);
-  }
-
-  private String routerSignature;
-  @Override
-  public String getRouterSignature() {
-    return this.routerSignature;
-  }
-
-  private String contact;
-  @Override
-  public String getContact() {
-    return this.contact;
-  }
-
-  private String[] familyEntries;
-  @Override
-  public List<String> getFamilyEntries() {
-    return this.familyEntries == null ? null :
-        Arrays.asList(this.familyEntries);
-  }
-
-  private BandwidthHistory readHistory;
-  @Override
-  public BandwidthHistory getReadHistory() {
-    return this.readHistory;
-  }
-
-  private BandwidthHistory writeHistory;
-  @Override
-  public BandwidthHistory getWriteHistory() {
-    return this.writeHistory;
-  }
-
-  private boolean usesEnhancedDnsLogic;
-  @Override
-  public boolean getUsesEnhancedDnsLogic() {
-    return this.usesEnhancedDnsLogic;
-  }
-
-  private boolean cachesExtraInfo;
-  @Override
-  public boolean getCachesExtraInfo() {
-    return this.cachesExtraInfo;
-  }
-
-  private String extraInfoDigest;
-  @Override
-  public String getExtraInfoDigest() {
-    return this.extraInfoDigest;
-  }
-
-  private String extraInfoDigestSha256;
-  @Override
-  public String getExtraInfoDigestSha256() {
-    return this.extraInfoDigestSha256;
-  }
-
-  private Integer[] hiddenServiceDirVersions;
-  @Override
-  public List<Integer> getHiddenServiceDirVersions() {
-    return this.hiddenServiceDirVersions == null ? null :
-        Arrays.asList(this.hiddenServiceDirVersions);
-  }
-
-  private Integer[] linkProtocolVersions;
-  @Override
-  public List<Integer> getLinkProtocolVersions() {
-    return this.linkProtocolVersions == null ? null :
-        Arrays.asList(this.linkProtocolVersions);
-  }
-
-  private Integer[] circuitProtocolVersions;
-  @Override
-  public List<Integer> getCircuitProtocolVersions() {
-    return this.circuitProtocolVersions == null ? null :
-        Arrays.asList(this.circuitProtocolVersions);
-  }
-
-  private boolean allowSingleHopExits;
-  @Override
-  public boolean getAllowSingleHopExits() {
-    return this.allowSingleHopExits;
-  }
-
-  private String ipv6DefaultPolicy;
-  @Override
-  public String getIpv6DefaultPolicy() {
-    return this.ipv6DefaultPolicy;
-  }
-
-  private String ipv6PortList;
-  @Override
-  public String getIpv6PortList() {
-    return this.ipv6PortList;
-  }
-
-  private String ntorOnionKey;
-  @Override
-  public String getNtorOnionKey() {
-    return this.ntorOnionKey;
-  }
-
-  private String identityEd25519;
-  @Override
-  public String getIdentityEd25519() {
-    return this.identityEd25519;
-  }
-
-  private String masterKeyEd25519;
-  @Override
-  public String getMasterKeyEd25519() {
-    return this.masterKeyEd25519;
-  }
-
-  private String routerSignatureEd25519;
-  @Override
-  public String getRouterSignatureEd25519() {
-    return this.routerSignatureEd25519;
-  }
-
-  private String onionKeyCrosscert;
-  @Override
-  public String getOnionKeyCrosscert() {
-    return this.onionKeyCrosscert;
-  }
-
-  private String ntorOnionKeyCrosscert;
-  @Override
-  public String getNtorOnionKeyCrosscert() {
-    return this.ntorOnionKeyCrosscert;
-  }
-
-  private int ntorOnionKeyCrosscertSign = -1;
-  @Override
-  public int getNtorOnionKeyCrosscertSign() {
-    return ntorOnionKeyCrosscertSign;
-  }
-
-  private boolean tunnelledDirServer;
-  @Override
-  public boolean getTunnelledDirServer() {
-    return this.tunnelledDirServer;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/impl/TorperfResultImpl.java b/src/org/torproject/descriptor/impl/TorperfResultImpl.java
deleted file mode 100644
index 0800de0..0000000
--- a/src/org/torproject/descriptor/impl/TorperfResultImpl.java
+++ /dev/null
@@ -1,546 +0,0 @@
-/* Copyright 2012--2016 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Scanner;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.TorperfResult;
-
-public class TorperfResultImpl extends DescriptorImpl
-    implements TorperfResult {
-
-  protected static List<Descriptor> parseTorperfResults(
-      byte[] rawDescriptorBytes, boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    if (rawDescriptorBytes.length == 0) {
-      throw new DescriptorParseException("Descriptor is empty.");
-    }
-    List<Descriptor> parsedDescriptors = new ArrayList<>();
-    String descriptorString = new String(rawDescriptorBytes);
-    Scanner s = new Scanner(descriptorString).useDelimiter("\r?\n");
-    String typeAnnotation = "";
-    while (s.hasNext()) {
-      String line = s.next();
-      if (line.startsWith("@type torperf ")) {
-        String[] parts = line.split(" ");
-        if (parts.length != 3) {
-          throw new DescriptorParseException("Illegal line '" + line
-              + "'.");
-        }
-        String version = parts[2];
-        if (!version.startsWith("1.")) {
-          throw new DescriptorParseException("Unsupported version in "
-              + " line '" + line + "'.");
-        }
-        typeAnnotation = line + "\n";
-      } else {
-        parsedDescriptors.add(new TorperfResultImpl(
-            (typeAnnotation + line).getBytes(),
-            failUnrecognizedDescriptorLines));
-        typeAnnotation = "";
-      }
-    }
-    return parsedDescriptors;
-  }
-
-  protected TorperfResultImpl(byte[] rawDescriptorBytes,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    super(rawDescriptorBytes, failUnrecognizedDescriptorLines, false);
-    this.parseTorperfResultLine(new String(rawDescriptorBytes));
-  }
-
-  private void parseTorperfResultLine(String inputLine)
-      throws DescriptorParseException {
-    String line = inputLine;
-    while (line.startsWith("@") && line.contains("\n")) {
-      line = line.split("\n")[1];
-    }
-    if (line.isEmpty()) {
-      throw new DescriptorParseException("Blank lines are not allowed.");
-    }
-    String[] parts = line.split(" ");
-    for (int i = 0; i < parts.length; i++) {
-      String keyValue = parts[i];
-      String[] keyValueParts = keyValue.split("=");
-      if (keyValueParts.length != 2) {
-        throw new DescriptorParseException("Illegal key-value pair in "
-            + "line '" + line + "'.");
-      }
-      String key = keyValueParts[0];
-      this.markKeyAsParsed(key, line);
-      String value = keyValueParts[1];
-      switch (key) {
-      case "SOURCE":
-        this.parseSource(value, keyValue, line);
-        break;
-      case "FILESIZE":
-        this.parseFileSize(value, keyValue, line);
-        break;
-      case "START":
-        this.parseStart(value, keyValue, line);
-        break;
-      case "SOCKET":
-        this.parseSocket(value, keyValue, line);
-        break;
-      case "CONNECT":
-        this.parseConnect(value, keyValue, line);
-        break;
-      case "NEGOTIATE":
-        this.parseNegotiate(value, keyValue, line);
-        break;
-      case "REQUEST":
-        this.parseRequest(value, keyValue, line);
-        break;
-      case "RESPONSE":
-        this.parseResponse(value, keyValue, line);
-        break;
-      case "DATAREQUEST":
-        this.parseDataRequest(value, keyValue, line);
-        break;
-      case "DATARESPONSE":
-        this.parseDataResponse(value, keyValue, line);
-        break;
-      case "DATACOMPLETE":
-        this.parseDataComplete(value, keyValue, line);
-        break;
-      case "WRITEBYTES":
-        this.parseWriteBytes(value, keyValue, line);
-        break;
-      case "READBYTES":
-        this.parseReadBytes(value, keyValue, line);
-        break;
-      case "DIDTIMEOUT":
-        this.parseDidTimeout(value, keyValue, line);
-        break;
-      case "LAUNCH":
-        this.parseLaunch(value, keyValue, line);
-        break;
-      case "USED_AT":
-        this.parseUsedAt(value, keyValue, line);
-        break;
-      case "PATH":
-        this.parsePath(value, keyValue, line);
-        break;
-      case "BUILDTIMES":
-        this.parseBuildTimes(value, keyValue, line);
-        break;
-      case "TIMEOUT":
-        this.parseTimeout(value, keyValue, line);
-        break;
-      case "QUANTILE":
-        this.parseQuantile(value, keyValue, line);
-        break;
-      case "CIRC_ID":
-        this.parseCircId(value, keyValue, line);
-        break;
-      case "USED_BY":
-        this.parseUsedBy(value, keyValue, line);
-        break;
-      default:
-        if (key.startsWith("DATAPERC")) {
-          this.parseDataPercentile(value, keyValue, line);
-        } else if (this.failUnrecognizedDescriptorLines) {
-          throw new DescriptorParseException("Unrecognized key '" + key
-              + "' in line '" + line + "'.");
-        } else {
-          if (this.unrecognizedKeys == null) {
-            this.unrecognizedKeys = new TreeMap<>();
-          }
-          this.unrecognizedKeys.put(key, value);
-          if (this.unrecognizedLines == null) {
-            this.unrecognizedLines = new ArrayList<>();
-          }
-          if (!this.unrecognizedLines.contains(line)) {
-            this.unrecognizedLines.add(line);
-          }
-        }
-      }
-    }
-    this.checkAllRequiredKeysParsed(line);
-  }
-
-  private Set<String> parsedKeys = new HashSet<>();
-  private Set<String> requiredKeys = new HashSet<>(Arrays.asList(
-      ("SOURCE,FILESIZE,START,SOCKET,CONNECT,NEGOTIATE,REQUEST,RESPONSE,"
-      + "DATAREQUEST,DATARESPONSE,DATACOMPLETE,WRITEBYTES,READBYTES").
-      split(",")));
-  private void markKeyAsParsed(String key, String line)
-      throws DescriptorParseException {
-    if (this.parsedKeys.contains(key)) {
-      throw new DescriptorParseException("Key '" + key + "' is contained "
-          + "at least twice in line '" + line + "', but must be "
-          + "contained at most once.");
-    }
-    this.parsedKeys.add(key);
-    this.requiredKeys.remove(key);
-  }
-  private void checkAllRequiredKeysParsed(String line)
-      throws DescriptorParseException {
-    for (String key : this.requiredKeys) {
-      throw new DescriptorParseException("Key '" + key + "' is contained "
-          + "contained 0 times in line '" + line + "', but must be "
-          + "contained exactly once.");
-    }
-  }
-
-  private void parseSource(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.source = value;
-  }
-
-  private void parseFileSize(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    try {
-      this.fileSize = Integer.parseInt(value);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal value in '" + keyValue
-          + "' in line '" + line + "'.");
-    }
-  }
-
-  private void parseStart(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.startMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseSocket(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.socketMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseConnect(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.connectMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseNegotiate(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.negotiateMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseRequest(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.requestMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseResponse(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.responseMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseDataRequest(String value, String keyValue,
-      String line) throws DescriptorParseException {
-    this.dataRequestMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseDataResponse(String value, String keyValue,
-      String line) throws DescriptorParseException {
-    this.dataResponseMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseDataComplete(String value, String keyValue,
-      String line) throws DescriptorParseException {
-    this.dataCompleteMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseWriteBytes(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.writeBytes = parseInt(value, keyValue, line);
-  }
-
-  private void parseReadBytes(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.readBytes = parseInt(value, keyValue, line);
-  }
-
-  private void parseDidTimeout(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    if (value.equals("1")) {
-      this.didTimeout = true;
-    } else if (value.equals("0")) {
-      this.didTimeout = false;
-    } else {
-      throw new DescriptorParseException("Illegal value in '" + keyValue
-          + "' in line '" + line + "'.");
-    }
-  }
-
-  private void parseDataPercentile(String value, String keyValue,
-      String line) throws DescriptorParseException {
-    String key = keyValue.substring(0, keyValue.indexOf("="));
-    String percentileString = key.substring("DATAPERC".length());
-    int percentile = -1;
-    try {
-      percentile = Integer.parseInt(percentileString);
-    } catch (NumberFormatException e) {
-      /* Treat key as unrecognized below. */
-      percentile = -1;
-    }
-    if (percentile < 0 || percentile > 100) {
-      if (this.unrecognizedKeys == null) {
-        this.unrecognizedKeys = new TreeMap<>();
-      }
-      this.unrecognizedKeys.put(key, value);
-    } else {
-      long timestamp = this.parseTimestamp(value, keyValue, line);
-      if (this.dataPercentiles == null) {
-        this.dataPercentiles = new TreeMap<>();
-      }
-      this.dataPercentiles.put(percentile, timestamp);
-    }
-  }
-
-  private void parseLaunch(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.launchMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parseUsedAt(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.usedAtMillis = this.parseTimestamp(value, keyValue, line);
-  }
-
-  private void parsePath(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    String[] valueParts = value.split(",");
-    String[] result = new String[valueParts.length];
-    for (int i = 0; i < valueParts.length; i++) {
-      if (valueParts[i].length() != 41) {
-        throw new DescriptorParseException("Illegal value in '" + keyValue
-            + "' in line '" + line + "'.");
-      }
-      result[i] = ParseHelper.parseTwentyByteHexString(line,
-          valueParts[i].substring(1));
-    }
-    this.path = result;
-  }
-
-  private void parseBuildTimes(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    String[] valueParts = value.split(",");
-    Long[] result = new Long[valueParts.length];
-    for (int i = 0; i < valueParts.length; i++) {
-      result[i] = this.parseTimestamp(valueParts[i], keyValue, line);
-    }
-    this.buildTimes = result;
-  }
-
-  private void parseTimeout(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.timeout = this.parseInt(value, keyValue, line);
-  }
-
-  private void parseQuantile(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.quantile = this.parseDouble(value, keyValue, line);
-  }
-
-  private void parseCircId(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.circId = this.parseInt(value, keyValue, line);
-  }
-
-  private void parseUsedBy(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    this.usedBy = this.parseInt(value, keyValue, line);
-  }
-
-  private long parseTimestamp(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    long timestamp = -1L;
-    if (value.contains(".") && value.split("\\.").length == 2) {
-      String zeroPaddedValue = (value + "000");
-      String threeDecimalPlaces = zeroPaddedValue.substring(0,
-          zeroPaddedValue.indexOf(".") + 4);
-      String millisString = threeDecimalPlaces.replaceAll("\\.", "");
-      try {
-        timestamp = Long.parseLong(millisString);
-      } catch (NumberFormatException e) {
-        /* Handle below. */
-      }
-    }
-    if (timestamp < 0L) {
-      throw new DescriptorParseException("Illegal timestamp '" + value
-          + "' in '" + keyValue + "' in line '" + line + "'.");
-    }
-    return timestamp;
-  }
-
-  private int parseInt(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    try {
-      return Integer.parseInt(value);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal value in '" + keyValue
-          + "' in line '" + line + "'.");
-    }
-  }
-
-  private double parseDouble(String value, String keyValue, String line)
-      throws DescriptorParseException {
-    try {
-      return Double.parseDouble(value);
-    } catch (NumberFormatException e) {
-      throw new DescriptorParseException("Illegal value in '" + keyValue
-          + "' in line '" + line + "'.");
-    }
-  }
-
-  private SortedMap<String, String> unrecognizedKeys;
-  @Override
-  public SortedMap<String, String> getUnrecognizedKeys() {
-    return this.unrecognizedKeys == null ? null
-        : new TreeMap<>(this.unrecognizedKeys);
-  }
-
-  private String source;
-  @Override
-  public String getSource() {
-    return this.source;
-  }
-
-  private int fileSize;
-  @Override
-  public int getFileSize() {
-    return this.fileSize;
-  }
-
-  private long startMillis;
-  @Override
-  public long getStartMillis() {
-    return this.startMillis;
-  }
-
-  private long socketMillis;
-  @Override
-  public long getSocketMillis() {
-    return this.socketMillis;
-  }
-
-  private long connectMillis;
-  @Override
-  public long getConnectMillis() {
-    return this.connectMillis;
-  }
-
-  private long negotiateMillis;
-  @Override
-  public long getNegotiateMillis() {
-    return this.negotiateMillis;
-  }
-
-  private long requestMillis;
-  @Override
-  public long getRequestMillis() {
-    return this.requestMillis;
-  }
-
-  private long responseMillis;
-  @Override
-  public long getResponseMillis() {
-    return this.responseMillis;
-  }
-
-  private long dataRequestMillis;
-  @Override
-  public long getDataRequestMillis() {
-    return this.dataRequestMillis;
-  }
-
-  private long dataResponseMillis;
-  @Override
-  public long getDataResponseMillis() {
-    return this.dataResponseMillis;
-  }
-
-  private long dataCompleteMillis;
-  @Override
-  public long getDataCompleteMillis() {
-    return this.dataCompleteMillis;
-  }
-
-  private int writeBytes;
-  @Override
-  public int getWriteBytes() {
-    return this.writeBytes;
-  }
-
-  private int readBytes;
-  @Override
-  public int getReadBytes() {
-    return this.readBytes;
-  }
-
-  private boolean didTimeout;
-  @Override
-  public Boolean didTimeout() {
-    return this.didTimeout;
-  }
-
-  private SortedMap<Integer, Long> dataPercentiles;
-  @Override
-  public SortedMap<Integer, Long> getDataPercentiles() {
-    return this.dataPercentiles == null ? null
-        : new TreeMap<>(this.dataPercentiles);
-  }
-
-  private long launchMillis = -1L;
-  @Override
-  public long getLaunchMillis() {
-    return this.launchMillis;
-  }
-
-  private long usedAtMillis = -1L;
-  @Override
-  public long getUsedAtMillis() {
-    return this.usedAtMillis;
-  }
-
-  private String[] path;
-  @Override
-  public List<String> getPath() {
-    return this.path == null ? null : Arrays.asList(this.path);
-  }
-
-  private Long[] buildTimes;
-  @Override
-  public List<Long> getBuildTimes() {
-    return this.buildTimes == null ? null :
-        Arrays.asList(this.buildTimes);
-  }
-
-  private long timeout = -1L;
-  @Override
-  public long getTimeout() {
-    return this.timeout;
-  }
-
-  private double quantile = -1.0;
-  @Override
-  public double getQuantile() {
-    return this.quantile;
-  }
-
-  private int circId = -1;
-  @Override
-  public int getCircId() {
-    return this.circId;
-  }
-
-  private int usedBy = -1;
-  @Override
-  public int getUsedBy() {
-    return this.usedBy;
-  }
-}
-
diff --git a/src/org/torproject/descriptor/package-info.java b/src/org/torproject/descriptor/package-info.java
deleted file mode 100644
index 5b34554..0000000
--- a/src/org/torproject/descriptor/package-info.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/* Copyright 2016 The Tor Project
- * See LICENSE for licensing information */
-
-/**
- * Interfaces and essential classes for obtaining and processing Tor
- * descriptors.
- *
- * <p>This package contains all relevant interfaces and
- * classes that an application would need to use this library.
- * Applications are strongly discouraged from accessing types from the
- * implementation package ({@code org.torproject.descriptor.impl})
- * directly, because those may change without prior notice.</p>
- *
- * <p>Interfaces and classes in this package can be grouped into
- * general-purpose types to obtain and process any type of descriptor and
- * descriptors produced by different components of the Tor network:</p>
- *
- * <ol>
- * <li>General-purpose types comprise
- * {@link org.torproject.descriptor.DescriptorSourceFactory} which is the
- * main entry point into using this library.  This factory is used to
- * create the descriptor sources for obtaining remote descriptor data
- * ({@link org.torproject.descriptor.DescriptorDownloader} and
- * {@link org.torproject.descriptor.DescriptorCollector}) and descriptor
- * sources for processing local descriptor data
- * ({@link org.torproject.descriptor.DescriptorReader} and
- * {@link org.torproject.descriptor.DescriptorParser}).  General-purpose
- * types also include descriptor containers
- * ({@link org.torproject.descriptor.DescriptorRequest} and
- * {@link org.torproject.descriptor.DescriptorFile}) and the
- * superinterface for all provided descriptors
- * ({@link org.torproject.descriptor.Descriptor}).</li>
- *
- * <li>The first group of descriptors is published by relays and servers
- * in the Tor network.  These interfaces include server descriptors
- * ({@link org.torproject.descriptor.ServerDescriptor} with subinterfaces
- * {@link org.torproject.descriptor.RelayServerDescriptor} and
- * {@link org.torproject.descriptor.BridgeServerDescriptor}), extra-info
- * descriptors ({@link org.torproject.descriptor.ExtraInfoDescriptor} with
- * subinterfaces
- * {@link org.torproject.descriptor.RelayExtraInfoDescriptor} and
- * {@link org.torproject.descriptor.BridgeExtraInfoDescriptor}),
- * microdescriptors which are derived from server descriptors by the
- * directory authorities
- * ({@link org.torproject.descriptor.Microdescriptor}), and helper types
- * for parts of the aforementioned descriptors
- * ({@link org.torproject.descriptor.BandwidthHistory}).</li>
- *
- * <li>The second group of descriptors is generated by authoritative
- * directory servers that form an opinion about relays and bridges in the
- * Tor network.  These include descriptors specified in version 3 of the
- * directory protocol
- * ({@link org.torproject.descriptor.RelayNetworkStatusConsensus},
- * {@link org.torproject.descriptor.RelayNetworkStatusVote},
- * {@link org.torproject.descriptor.DirectoryKeyCertificate}, and helper
- * types for descriptor parts
- * {@link org.torproject.descriptor.DirSourceEntry},
- * {@link org.torproject.descriptor.NetworkStatusEntry}, and
- * {@link org.torproject.descriptor.DirectorySignature}), descriptors from
- * earlier directory protocol version 2
- * ({@link org.torproject.descriptor.RelayNetworkStatus}) and version 1
- * ({@link org.torproject.descriptor.RelayDirectory} and
- * {@link org.torproject.descriptor.RouterStatusEntry}), as well as
- * descriptors published by the bridge authority and sanitized by the
- * CollecTor service
- * ({@link org.torproject.descriptor.BridgeNetworkStatus}).</li>
- *
- * <li>The third group of descriptors is created by auxiliary services
- * connected to the Tor network rather than by the Tor software.  This
- * group comprises descriptors by the bridge distribution service BridgeDB
- * ({@link org.torproject.descriptor.BridgePoolAssignment}), the exit list
- * service TorDNSEL ({@link org.torproject.descriptor.ExitList}), and the
- * performance measurement service Torperf
- * ({@link org.torproject.descriptor.TorperfResult}).</li>
- * </ol>
- *
- * @since 1.0.0
- */
-package org.torproject.descriptor;
-
diff --git a/src/test/java/org/torproject/descriptor/benchmark/MeasurePerformance.java b/src/test/java/org/torproject/descriptor/benchmark/MeasurePerformance.java
new file mode 100644
index 0000000..a52020a
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/benchmark/MeasurePerformance.java
@@ -0,0 +1,278 @@
+/* Copyright 2016 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.benchmark;
+
+import org.torproject.descriptor.Descriptor;
+import org.torproject.descriptor.DescriptorFile;
+import org.torproject.descriptor.DescriptorReader;
+import org.torproject.descriptor.DescriptorSourceFactory;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+import org.torproject.descriptor.Microdescriptor;
+import org.torproject.descriptor.NetworkStatusEntry;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+import org.torproject.descriptor.ServerDescriptor;
+
+import java.io.File;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.SortedMap;
+
+public class MeasurePerformance {
+
+  /* Check if all necessary files are available and then measure
+   * performance of some more or less common use cases. */
+  public static void main(String[] args) {
+    if (!filesAvailable()) {
+      return;
+    }
+    measureAverageAdvertisedBandwidth(new File(resDir, resPaths[0]));
+    pause();
+    measureAverageAdvertisedBandwidth(new File(resDir, resPaths[1]));
+    pause();
+    measureAverageAdvertisedBandwidth(new File(resDir, resPaths[2]));
+    pause();
+    measureCountriesV3Requests(new File(resDir, resPaths[3]));
+    pause();
+    measureCountriesV3Requests(new File(resDir, resPaths[4]));
+    pause();
+    measureAverageRelaysExit(new File(resDir, resPaths[5]));
+    pause();
+    measureAverageRelaysExit(new File(resDir, resPaths[6]));
+    pause();
+    measureAverageRelaysExit(new File(resDir, resPaths[7]));
+    measureFractionRelaysExit80Microdescriptors(
+        new File(resDir, resPaths[8]));
+    measureFractionRelaysExit80Microdescriptors(
+        new File(resDir, resPaths[9]));
+  }
+
+  private static File resDir = new File("res");
+  private static String[] resPaths = new String[] {
+    "archive/relay-descriptors/server-descriptors/"
+        + "server-descriptors-2015-11.tar.xz",
+    "archive/relay-descriptors/server-descriptors/"
+        + "server-descriptors-2015-11.tar",
+    "archive/relay-descriptors/server-descriptors/"
+        + "server-descriptors-2015-11",
+    "archive/relay-descriptors/extra-infos/extra-infos-2015-11.tar.xz",
+    "archive/relay-descriptors/extra-infos/extra-infos-2015-11.tar",
+    "archive/relay-descriptors/consensuses/consensuses-2015-11.tar.xz",
+    "archive/relay-descriptors/consensuses/consensuses-2015-11.tar",
+    "archive/relay-descriptors/consensuses/consensuses-2015-11",
+    "archive/relay-descriptors/microdescs/microdescs-2015-11.tar.xz",
+    "archive/relay-descriptors/microdescs/microdescs-2015-11.tar"
+  };
+
+  private static boolean filesAvailable() {
+    if (!resDir.exists() || !resDir.isDirectory()) {
+      return false;
+    }
+    for (String resPath : resPaths) {
+      if (!(new File(resDir, resPath).exists())) {
+        System.err.println("Missing resource: " + resDir + "/" + resPath);
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static void pause() {
+    try {
+      Thread.sleep(15L * 1000L);
+    } catch (InterruptedException e) {
+      e.printStackTrace();
+    }
+  }
+
+  private static void measureAverageAdvertisedBandwidth(
+      File tarballFileOrDirectory) {
+    System.out.println("Starting measureAverageAdvertisedBandwidth");
+    long startedMillis = System.currentTimeMillis();
+    long sumAdvertisedBandwidth = 0, countedServerDescriptors = 0;
+    DescriptorReader descriptorReader =
+        DescriptorSourceFactory.createDescriptorReader();
+    descriptorReader.addTarball(tarballFileOrDirectory);
+    descriptorReader.addDirectory(tarballFileOrDirectory);
+    Iterator<DescriptorFile> descriptorFiles =
+        descriptorReader.readDescriptors();
+    while (descriptorFiles.hasNext()) {
+      DescriptorFile descriptorFile = descriptorFiles.next();
+      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
+        if (!(descriptor instanceof ServerDescriptor)) {
+          continue;
+        }
+        ServerDescriptor serverDescriptor = (ServerDescriptor) descriptor;
+        sumAdvertisedBandwidth += (long) Math.min(Math.min(
+            serverDescriptor.getBandwidthRate(),
+            serverDescriptor.getBandwidthBurst()),
+            serverDescriptor.getBandwidthObserved());
+        countedServerDescriptors++;
+      }
+    }
+    long endedMillis = System.currentTimeMillis();
+    System.out.println("Ending measureAverageAdvertisedBandwidth");
+    System.out.printf("Total time: %d millis%n",
+        endedMillis - startedMillis);
+    System.out.printf("Processed server descriptors: %d%n",
+        countedServerDescriptors);
+    System.out.printf("Average advertised bandwidth: %d%n",
+        sumAdvertisedBandwidth / countedServerDescriptors);
+    System.out.printf("Time per server descriptor: %.6f millis%n",
+        ((double) (endedMillis - startedMillis))
+        / ((double) countedServerDescriptors));
+  }
+
+  private static void measureCountriesV3Requests(File tarballFile) {
+    System.out.println("Starting measureCountriesV3Requests");
+    long startedMillis = System.currentTimeMillis();
+    Set<String> countries = new HashSet<>();
+    long countedExtraInfoDescriptors = 0;
+    DescriptorReader descriptorReader =
+        DescriptorSourceFactory.createDescriptorReader();
+    descriptorReader.addTarball(tarballFile);
+    Iterator<DescriptorFile> descriptorFiles =
+        descriptorReader.readDescriptors();
+    while (descriptorFiles.hasNext()) {
+      DescriptorFile descriptorFile = descriptorFiles.next();
+      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
+        if (!(descriptor instanceof ExtraInfoDescriptor)) {
+          continue;
+        }
+        ExtraInfoDescriptor extraInfoDescriptor =
+            (ExtraInfoDescriptor) descriptor;
+        SortedMap<String, Integer> dirreqV3Reqs =
+            extraInfoDescriptor.getDirreqV3Reqs();
+        if (dirreqV3Reqs != null) {
+          countries.addAll(dirreqV3Reqs.keySet());
+        }
+        countedExtraInfoDescriptors++;
+      }
+    }
+    long endedMillis = System.currentTimeMillis();
+    System.out.println("Ending measureCountriesV3Requests");
+    System.out.printf("Total time: %d millis%n",
+        endedMillis - startedMillis);
+    System.out.printf("Processed extra-info descriptors: %d%n",
+        countedExtraInfoDescriptors);
+    System.out.printf("Number of countries: %d%n",
+        countries.size());
+    System.out.printf("Time per extra-info descriptor: %.6f millis%n",
+        ((double) (endedMillis - startedMillis))
+        / ((double) countedExtraInfoDescriptors));
+  }
+
+  private static void measureAverageRelaysExit(
+      File tarballFileOrDirectory) {
+    System.out.println("Starting measureAverageRelaysExit");
+    long startedMillis = System.currentTimeMillis();
+    long totalRelaysWithExitFlag = 0L, totalRelays = 0L,
+        countedConsensuses = 0L;
+    DescriptorReader descriptorReader =
+        DescriptorSourceFactory.createDescriptorReader();
+    descriptorReader.addTarball(tarballFileOrDirectory);
+    descriptorReader.addDirectory(tarballFileOrDirectory);
+    Iterator<DescriptorFile> descriptorFiles =
+        descriptorReader.readDescriptors();
+    while (descriptorFiles.hasNext()) {
+      DescriptorFile descriptorFile = descriptorFiles.next();
+      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
+        if (!(descriptor instanceof RelayNetworkStatusConsensus)) {
+          continue;
+        }
+        RelayNetworkStatusConsensus consensus =
+            (RelayNetworkStatusConsensus) descriptor;
+        for (NetworkStatusEntry entry :
+            consensus.getStatusEntries().values()) {
+          if (entry.getFlags().contains("Exit")) {
+            totalRelaysWithExitFlag++;
+          }
+          totalRelays++;
+        }
+        countedConsensuses++;
+      }
+    }
+    long endedMillis = System.currentTimeMillis();
+    System.out.println("Ending measureAverageRelaysExit");
+    System.out.printf("Total time: %d millis%n",
+        endedMillis - startedMillis);
+    System.out.printf("Processed consensuses: %d%n", countedConsensuses);
+    System.out.printf("Total number of status entries: %d%n",
+        totalRelays);
+    System.out.printf("Total number of status entries with Exit flag: "
+        + "%d%n", totalRelaysWithExitFlag);
+    System.out.printf("Average number of relays with Exit Flag: %.2f%n",
+        (double) totalRelaysWithExitFlag / (double) totalRelays);
+    System.out.printf("Time per consensus: %.6f millis%n",
+        ((double) (endedMillis - startedMillis))
+        / ((double) countedConsensuses));
+  }
+
+  private static void measureFractionRelaysExit80Microdescriptors(
+      File tarballFile) {
+    System.out.println("Starting "
+        + "measureFractionRelaysExit80Microdescriptors");
+    long startedMillis = System.currentTimeMillis();
+    long totalRelaysWithExitFlag = 0L, countedMicrodescriptors = 0L;
+    DescriptorReader descriptorReader =
+        DescriptorSourceFactory.createDescriptorReader();
+    descriptorReader.addTarball(tarballFile);
+    Iterator<DescriptorFile> descriptorFiles =
+        descriptorReader.readDescriptors();
+    while (descriptorFiles.hasNext()) {
+      DescriptorFile descriptorFile = descriptorFiles.next();
+      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
+        if (!(descriptor instanceof Microdescriptor)) {
+          continue;
+        }
+        countedMicrodescriptors++;
+        Microdescriptor microdescriptor =
+            (Microdescriptor) descriptor;
+        String defaultPolicy = microdescriptor.getDefaultPolicy();
+        if (defaultPolicy == null) {
+          continue;
+        }
+        boolean accept = "accept".equals(
+            microdescriptor.getDefaultPolicy());
+        for (String ports : microdescriptor.getPortList().split(",")) {
+          if (ports.contains("-")) {
+            String[] parts = ports.split("-");
+            int from = Integer.parseInt(parts[0]);
+            int to = Integer.parseInt(parts[1]);
+            if (from <= 80 && to >= 80) {
+              if (accept) {
+                totalRelaysWithExitFlag++;
+              }
+            } else if (to > 80) {
+              if (!accept) {
+                totalRelaysWithExitFlag++;
+              }
+              break;
+            }
+          } else if ("80".equals(ports)) {
+            if (accept) {
+              totalRelaysWithExitFlag++;
+            }
+            break;
+          }
+        }
+      }
+    }
+    long endedMillis = System.currentTimeMillis();
+    System.out.println("Ending "
+        + "measureFractionRelaysExit80Microdescriptors");
+    System.out.printf("Total time: %d millis%n",
+        endedMillis - startedMillis);
+    System.out.printf("Processed microdescriptors: %d%n",
+        countedMicrodescriptors);
+    System.out.printf("Total number of microdescriptors that exit to 80: "
+        + "%d%n", totalRelaysWithExitFlag);
+    System.out.printf("Average number of relays that exit to 80: %.2f%n",
+        (double) totalRelaysWithExitFlag
+        / (double) countedMicrodescriptors);
+    System.out.printf("Time per microdescriptor: %.6f millis%n",
+        ((double) (endedMillis - startedMillis))
+        / ((double) countedMicrodescriptors));
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/BridgeNetworkStatusTest.java b/src/test/java/org/torproject/descriptor/impl/BridgeNetworkStatusTest.java
new file mode 100644
index 0000000..0847e13
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/BridgeNetworkStatusTest.java
@@ -0,0 +1,151 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+import org.torproject.descriptor.BridgeNetworkStatus;
+import org.torproject.descriptor.DescriptorParseException;
+
+/* Test parsing of bridge network statuses.  Some of the parsing code is
+ * already tested in the consensus/vote-parsing tests. */
+public class BridgeNetworkStatusTest {
+
+  /* Helper class to build a bridge network status based on default data
+   * and modifications requested by test methods. */
+  private static class StatusBuilder {
+    private String fileName = "20151121-173936-"
+        + "4A0CCD2DDC7995083D73F5D667100C8A5831F16D";
+    private static BridgeNetworkStatus
+        createWithFileName(String fileName)
+        throws DescriptorParseException {
+      StatusBuilder sb = new StatusBuilder();
+      sb.fileName = fileName;
+      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
+          true);
+    }
+    private String publishedLine = "published 2015-11-21 17:39:36";
+    private static BridgeNetworkStatus
+        createWithPublishedLine(String line)
+        throws DescriptorParseException {
+      StatusBuilder sb = new StatusBuilder();
+      sb.publishedLine = line;
+      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
+          true);
+    }
+    private String flagThresholdsLine = "flag-thresholds "
+        + "stable-uptime=3105080 stable-mtbf=2450615 fast-speed=55000 "
+        + "guard-wfu=98.000% guard-tk=691200 guard-bw-inc-exits=337000 "
+        + "guard-bw-exc-exits=339000 enough-mtbf=1 "
+        + "ignoring-advertised-bws=0";
+    private static BridgeNetworkStatus
+        createWithFlagThresholdsLine(String line)
+        throws DescriptorParseException {
+      StatusBuilder sb = new StatusBuilder();
+      sb.flagThresholdsLine = line;
+      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
+          true);
+    }
+    private List<String> statusEntries = new ArrayList<>();
+    private String unrecognizedHeaderLine = null;
+    protected static BridgeNetworkStatus
+        createWithUnrecognizedHeaderLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      StatusBuilder sb = new StatusBuilder();
+      sb.unrecognizedHeaderLine = line;
+      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
+          failUnrecognizedDescriptorLines);
+    }
+    private String unrecognizedStatusEntryLine = null;
+    protected static BridgeNetworkStatus
+        createWithUnrecognizedStatusEntryLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      StatusBuilder sb = new StatusBuilder();
+      sb.unrecognizedStatusEntryLine = line;
+      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
+          failUnrecognizedDescriptorLines);
+    }
+
+    private StatusBuilder() {
+      this.statusEntries.add("r Unnamed ABk0wg4j6BLCdZKleVtmNrfzJGI "
+          + "bh7gVU1Cz6+JG+7j4qGsF4prDi8 2015-11-21 15:46:25 "
+          + "10.153.163.200 443 0\ns Fast Running Stable Valid\n"
+          + "w Bandwidth=264\np reject 1-65535");
+    }
+    private byte[] buildStatus() {
+      StringBuilder sb = new StringBuilder();
+      this.appendHeader(sb);
+      this.appendStatusEntries(sb);
+      return sb.toString().getBytes();
+    }
+    private void appendHeader(StringBuilder sb) {
+      if (this.publishedLine != null) {
+        sb.append(this.publishedLine).append("\n");
+      }
+      if (this.flagThresholdsLine != null) {
+        sb.append(this.flagThresholdsLine).append("\n");
+      }
+      if (this.unrecognizedHeaderLine != null) {
+        sb.append(this.unrecognizedHeaderLine).append("\n");
+      }
+    }
+    private void appendStatusEntries(StringBuilder sb) {
+      for (String statusEntry : this.statusEntries) {
+        sb.append(statusEntry).append("\n");
+      }
+      if (this.unrecognizedStatusEntryLine != null) {
+        sb.append(this.unrecognizedStatusEntryLine).append("\n");
+      }
+    }
+  }
+
+  @Test()
+  public void testSampleStatus() throws DescriptorParseException {
+    StatusBuilder sb = new StatusBuilder();
+    BridgeNetworkStatus status =
+        new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName, true);
+    assertEquals(1448127576000L, status.getPublishedMillis());
+    assertEquals(3105080L, status.getStableUptime());
+    assertEquals(2450615L, status.getStableMtbf());
+    assertEquals(55000L, status.getFastBandwidth());
+    assertEquals(98.0, status.getGuardWfu(), 0.001);
+    assertEquals(691200L, status.getGuardTk());
+    assertEquals(337000L, status.getGuardBandwidthIncludingExits());
+    assertEquals(339000L, status.getGuardBandwidthExcludingExits());
+    assertEquals(1, status.getEnoughMtbfInfo());
+    assertEquals(0, status.getIgnoringAdvertisedBws());
+    assertEquals(264, status.getStatusEntries().get(
+        "001934C20E23E812C27592A5795B6636B7F32462").getBandwidth());
+    assertTrue(status.getUnrecognizedLines().isEmpty());
+  }
+
+  @Test()
+  public void testPublishedNoLine() throws DescriptorParseException {
+    BridgeNetworkStatus status =
+        StatusBuilder.createWithPublishedLine(null);
+    assertEquals(1448127576000L, status.getPublishedMillis());
+  }
+
+  @Test()
+  public void testFlagThresholdsNoLine() throws DescriptorParseException {
+    BridgeNetworkStatus status =
+        StatusBuilder.createWithFlagThresholdsLine(null);
+    assertEquals(-1L, status.getStableUptime());
+    assertEquals(-1L, status.getStableMtbf());
+    assertEquals(-1L, status.getFastBandwidth());
+    assertEquals(-1.0, status.getGuardWfu(), 0.001);
+    assertEquals(-1L, status.getGuardTk());
+    assertEquals(-1L, status.getGuardBandwidthIncludingExits());
+    assertEquals(-1L, status.getGuardBandwidthExcludingExits());
+    assertEquals(-1, status.getEnoughMtbfInfo());
+    assertEquals(-1, status.getIgnoringAdvertisedBws());
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/ConsensusBuilder.java b/src/test/java/org/torproject/descriptor/impl/ConsensusBuilder.java
new file mode 100644
index 0000000..29a2d47
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/ConsensusBuilder.java
@@ -0,0 +1,321 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+
+/* Helper class to build a consensus based on default data and
+ * modifications requested by test methods. */
+public class ConsensusBuilder {
+  String networkStatusVersionLine = "network-status-version 3";
+  protected static RelayNetworkStatusConsensus
+      createWithNetworkStatusVersionLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.networkStatusVersionLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String voteStatusLine = "vote-status consensus";
+  protected static RelayNetworkStatusConsensus
+      createWithVoteStatusLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.voteStatusLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String consensusMethodLine = "consensus-method 11";
+  protected static RelayNetworkStatusConsensus
+      createWithConsensusMethodLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.consensusMethodLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String validAfterLine = "valid-after 2011-11-30 09:00:00";
+  protected static RelayNetworkStatusConsensus
+      createWithValidAfterLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.validAfterLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String freshUntilLine = "fresh-until 2011-11-30 10:00:00";
+  protected static RelayNetworkStatusConsensus
+      createWithFreshUntilLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.freshUntilLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String validUntilLine = "valid-until 2011-11-30 12:00:00";
+  protected static RelayNetworkStatusConsensus
+      createWithValidUntilLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.validUntilLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String votingDelayLine = "voting-delay 300 300";
+  protected static RelayNetworkStatusConsensus
+      createWithVotingDelayLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.votingDelayLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  String clientVersionsLine = "client-versions 0.2.1.31,"
+      + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
+  protected static RelayNetworkStatusConsensus
+      createWithClientVersionsLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.clientVersionsLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  String serverVersionsLine = "server-versions 0.2.1.31,"
+      + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
+  protected static RelayNetworkStatusConsensus
+      createWithServerVersionsLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.serverVersionsLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String packageLines = null;
+  protected static RelayNetworkStatusConsensus
+      createWithPackageLines(String lines)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.packageLines = lines;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String knownFlagsLine = "known-flags Authority BadExit Exit "
+      + "Fast Guard HSDir Named Running Stable Unnamed V2Dir Valid";
+  protected static RelayNetworkStatusConsensus
+      createWithKnownFlagsLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.knownFlagsLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String paramsLine = "params "
+      + "CircuitPriorityHalflifeMsec=30000 bwauthbestratio=1 "
+      + "bwauthcircs=1 bwauthdescbw=0 bwauthkp=10000 bwauthpid=1 "
+      + "bwauthtd=5000 bwauthti=50000 bwauthtidecay=5000 cbtnummodes=3 "
+      + "cbtquantile=80 circwindow=1000 refuseunknownexits=1";
+  protected static RelayNetworkStatusConsensus
+      createWithParamsLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.paramsLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  List<String> dirSources = new ArrayList<>();
+  List<String> statusEntries = new ArrayList<>();
+  private String directoryFooterLine = "directory-footer";
+  protected void setDirectoryFooterLine(String line) {
+    this.directoryFooterLine = line;
+  }
+  protected static RelayNetworkStatusConsensus
+      createWithDirectoryFooterLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.directoryFooterLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private String bandwidthWeightsLine = "bandwidth-weights Wbd=285 "
+      + "Wbe=0 Wbg=0 Wbm=10000 Wdb=10000 Web=10000 Wed=1021 Wee=10000 "
+      + "Weg=1021 Wem=10000 Wgb=10000 Wgd=8694 Wgg=10000 Wgm=10000 "
+      + "Wmb=10000 Wmd=285 Wme=0 Wmg=0 Wmm=10000";
+  protected void setBandwidthWeightsLine(String line) {
+    this.bandwidthWeightsLine = line;
+  }
+  protected static RelayNetworkStatusConsensus
+      createWithBandwidthWeightsLine(String line)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.bandwidthWeightsLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+  private List<String> directorySignatures = new ArrayList<>();
+  protected void addDirectorySignature(String directorySignatureString) {
+    this.directorySignatures.add(directorySignatureString);
+  }
+  private String unrecognizedHeaderLine = null;
+  protected static RelayNetworkStatusConsensus
+      createWithUnrecognizedHeaderLine(String line,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.unrecognizedHeaderLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+        failUnrecognizedDescriptorLines);
+  }
+  private String unrecognizedDirSourceLine = null;
+  protected static RelayNetworkStatusConsensus
+      createWithUnrecognizedDirSourceLine(String line,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.unrecognizedDirSourceLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+        failUnrecognizedDescriptorLines);
+  }
+  private String unrecognizedStatusEntryLine = null;
+  protected static RelayNetworkStatusConsensus
+      createWithUnrecognizedStatusEntryLine(String line,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.unrecognizedStatusEntryLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+        failUnrecognizedDescriptorLines);
+  }
+  private String unrecognizedFooterLine = null;
+  protected static RelayNetworkStatusConsensus
+      createWithUnrecognizedFooterLine(String line,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.unrecognizedFooterLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+        failUnrecognizedDescriptorLines);
+  }
+  private String unrecognizedDirectorySignatureLine = null;
+  protected static RelayNetworkStatusConsensus
+      createWithUnrecognizedDirectorySignatureLine(String line,
+      boolean failUnrecognizedDescriptorLines)
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.unrecognizedDirectorySignatureLine = line;
+    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+        failUnrecognizedDescriptorLines);
+  }
+
+  protected ConsensusBuilder() {
+    this.dirSources.add("dir-source tor26 "
+        + "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4 86.59.21.38 "
+        + "86.59.21.38 80 443\ncontact Peter Palfrader\nvote-digest "
+        + "0333880AA67ED7E07C11108656D0C8D6DD1C7E5D");
+    this.dirSources.add("dir-source ides "
+        + "27B6B5996C426270A5C95488AA5BCEB6BCC86956 216.224.124.114 "
+        + "216.224.124.114 9030 9090\ncontact Mike Perry "
+        + "<mikeperryTAfsckedTODorg>\nvote-digest "
+        + "1A8827ECD53184F7A771EFA9B3D30DC473FE8670");
+    this.statusEntries.add("r ANONIONROUTER "
+        + "AHhuQ8zFQJdT8l42Axxc6m6kNwI yEMZ5B/JQixNZgC1+2rLe0pR9rU "
+        + "2011-11-30 02:52:58 93.128.66.111 24051 24052\ns Exit Fast "
+        + "Named Running V2Dir Valid\nv Tor 0.2.2.34\nw "
+        + "Bandwidth=1100\np reject 25,119,135-139,6881-6999");
+    this.statusEntries.add("r Magellan AHlabo2RwnD8I7MPOIpJVVPgGJQ "
+        + "rB/7uzI4mU38bZ9cSXEy+Z/4Cuk 2011-11-30 05:37:35 "
+        + "188.177.149.216 9001 9030\ns Fast Named Running V2Dir "
+        + "Valid\nv Tor 0.2.2.34\nw Bandwidth=367\np reject 1-65535");
+    this.directorySignatures.add("directory-signature "
+        + "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4 "
+        + "3509BA5A624403A905C74DA5C8A0CEC9E0D3AF86\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "NYRcTWAMRiYYiGW0hIbzeZKU6sefg98AwwXrQUCudO8wfA1cfgttTDoscB9I"
+        + "TbOY\nr+c30jV/qQCMamTAEDGgJTw8KghI32vytupKallI1EjCOF8UvL1UnA"
+        + "LgpaR7sZ3W\n7WQZVVrWDtnYaULOEKfwnGnRC7WwE+YRSysbzwwCVs0=\n"
+        + "-----END SIGNATURE-----");
+    this.directorySignatures.add("directory-signature "
+        + "27B6B5996C426270A5C95488AA5BCEB6BCC86956 "
+        + "D5C30C15BB3F1DA27669C2D88439939E8F418FCF\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "DzFPj3vyYrCv0W3r8qDPJPlmeLnadY+drjWkdOqO66Ih/hAWBb9KcBJAX1sX"
+        + "aDA7\n/iSaDhduBXuJdcu8lbmMP8d6uYBdRjHXqWDXySUZAkSfPB4JJPNGvf"
+        + "oQA/qeby7E\n5374pPPL6WwCLJHkKtk21S9oHDmFBdlZq7JWQelWlVM=\n"
+        + "-----END SIGNATURE-----");
+  }
+  protected byte[] buildConsensus() {
+    StringBuilder sb = new StringBuilder();
+    this.appendHeader(sb);
+    this.appendDirSources(sb);
+    this.appendStatusEntries(sb);
+    this.appendFooter(sb);
+    this.appendDirectorySignatures(sb);
+    return sb.toString().getBytes();
+  }
+  private void appendHeader(StringBuilder sb) {
+    if (this.networkStatusVersionLine != null) {
+      sb.append(this.networkStatusVersionLine).append("\n");
+    }
+    if (this.voteStatusLine != null) {
+      sb.append(this.voteStatusLine).append("\n");
+    }
+    if (this.consensusMethodLine != null) {
+      sb.append(this.consensusMethodLine).append("\n");
+    }
+    if (this.validAfterLine != null) {
+      sb.append(this.validAfterLine).append("\n");
+    }
+    if (this.freshUntilLine != null) {
+      sb.append(this.freshUntilLine).append("\n");
+    }
+    if (this.validUntilLine != null) {
+      sb.append(this.validUntilLine).append("\n");
+    }
+    if (this.votingDelayLine != null) {
+      sb.append(this.votingDelayLine).append("\n");
+    }
+    if (this.clientVersionsLine != null) {
+      sb.append(this.clientVersionsLine).append("\n");
+    }
+    if (this.serverVersionsLine != null) {
+      sb.append(this.serverVersionsLine).append("\n");
+    }
+    if (this.packageLines != null) {
+      sb.append(this.packageLines).append("\n");
+    }
+    if (this.knownFlagsLine != null) {
+      sb.append(this.knownFlagsLine).append("\n");
+    }
+    if (this.paramsLine != null) {
+      sb.append(this.paramsLine).append("\n");
+    }
+    if (this.unrecognizedHeaderLine != null) {
+      sb.append(this.unrecognizedHeaderLine).append("\n");
+    }
+  }
+  private void appendDirSources(StringBuilder sb) {
+    for (String dirSource : this.dirSources) {
+      sb.append(dirSource).append("\n");
+    }
+    if (this.unrecognizedDirSourceLine != null) {
+      sb.append(this.unrecognizedDirSourceLine).append("\n");
+    }
+  }
+  private void appendStatusEntries(StringBuilder sb) {
+    for (String statusEntry : this.statusEntries) {
+      sb.append(statusEntry).append("\n");
+    }
+    if (this.unrecognizedStatusEntryLine != null) {
+      sb.append(this.unrecognizedStatusEntryLine).append("\n");
+    }
+  }
+  private void appendFooter(StringBuilder sb) {
+    if (this.directoryFooterLine != null) {
+      sb.append(this.directoryFooterLine).append("\n");
+    }
+    if (this.bandwidthWeightsLine != null) {
+      sb.append(this.bandwidthWeightsLine).append("\n");
+    }
+    if (this.unrecognizedFooterLine != null) {
+      sb.append(this.unrecognizedFooterLine).append("\n");
+    }
+  }
+  private void appendDirectorySignatures(StringBuilder sb) {
+    for (String directorySignature : this.directorySignatures) {
+      sb.append(directorySignature).append("\n");
+    }
+    if (this.unrecognizedDirectorySignatureLine != null) {
+      sb.append(this.unrecognizedDirectorySignatureLine).append("\n");
+    }
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/DescriptorCollectorImplTest.java b/src/test/java/org/torproject/descriptor/impl/DescriptorCollectorImplTest.java
new file mode 100644
index 0000000..fde8e57
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/DescriptorCollectorImplTest.java
@@ -0,0 +1,134 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import java.util.SortedMap;
+
+import org.junit.Test;
+
+public class DescriptorCollectorImplTest {
+
+  private static final String REMOTE_DIRECTORY_CONSENSUSES =
+      "/recent/relay-descriptors/consensuses/";
+
+  @Test()
+  public void testOneFile() {
+    String remoteFilename = "2015-05-24-12-00-00-consensus";
+    String directoryListing = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
+        + "<a href=\"" + remoteFilename + "\">"
+        + "2015-05-24-12-00-00-consensus</a></td>"
+        + "<td align=\"right\">24-May-2015 12:08  </td>"
+        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
+    SortedMap<String, Long> remoteFiles =
+        new DescriptorCollectorImpl().parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNotNull(remoteFiles);
+    assertSame(1, remoteFiles.size());
+    assertEquals(REMOTE_DIRECTORY_CONSENSUSES + remoteFilename,
+        remoteFiles.firstKey());
+    assertEquals((Long) 1432469280000L,
+        remoteFiles.get(remoteFiles.firstKey()));
+  }
+
+  @Test()
+  public void testSameFileTwoTimestampsLastWins() {
+    String remoteFilename = "2015-05-24-12-00-00-consensus";
+    String firstTimestamp = "24-May-2015 12:04";
+    String secondTimestamp = "24-May-2015 12:08";
+    String lineFormat = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
+        + "<a href=\"%s\">2015-05-24-12-00-00-consensus</a></td>"
+        + "<td align=\"right\">%s  </td>"
+        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>\n";
+    String directoryListing = String.format(lineFormat + lineFormat,
+        remoteFilename, firstTimestamp, remoteFilename, secondTimestamp);
+    SortedMap<String, Long> remoteFiles =
+        new DescriptorCollectorImpl().parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNotNull(remoteFiles);
+    assertSame(1, remoteFiles.size());
+    assertEquals(REMOTE_DIRECTORY_CONSENSUSES + remoteFilename,
+        remoteFiles.firstKey());
+    assertEquals((Long) 1432469280000L,
+        remoteFiles.get(remoteFiles.firstKey()));
+  }
+
+  @Test()
+  public void testSubDirectoryOnly() {
+    String directoryListing = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/folder.gif\" alt=\"[DIR]\"></td><td>"
+        + "<a href=\"subdir/\">subdir/</a></td>"
+        + "<td align=\"right\">27-May-2015 14:07  </td>"
+        + "<td align=\"right\">  - </td><td>&nbsp;</td></tr>";
+    DescriptorCollectorImpl collector = new DescriptorCollectorImpl();
+    SortedMap<String, Long> remoteFiles = collector.parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNotNull(remoteFiles);
+    assertTrue(remoteFiles.isEmpty());
+  }
+
+  @Test()
+  public void testParentDirectoryOnly() {
+    String directoryListing = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/back.gif\" alt=\"[DIR]\"></td><td>"
+        + "<a href=\"/recent/relay-descriptors/\">Parent Directory</a>"
+        + "</td><td>&nbsp;</td><td align=\"right\">  - </td>"
+        + "<td>&nbsp;</td></tr>";
+    DescriptorCollectorImpl collector = new DescriptorCollectorImpl();
+    SortedMap<String, Long> remoteFiles = collector.parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNotNull(remoteFiles);
+    assertTrue(remoteFiles.isEmpty());
+  }
+
+  @Test()
+  public void testUnexpectedDateFormat() {
+    String directoryListing = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
+        + "<a href=\"2015-05-24-12-00-00-consensus\">"
+        + "2015-05-24-12-00-00-consensus</a></td>"
+        + "<td align=\"right\">2015-05-24 12:08  </td>"
+        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
+    SortedMap<String, Long> remoteFiles =
+        new DescriptorCollectorImpl().parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNotNull(remoteFiles);
+    assertTrue(remoteFiles.isEmpty());
+  }
+
+  @Test()
+  public void testInvalidDate() {
+    String directoryListing = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
+        + "<a href=\"2015-05-24-12-00-00-consensus\">"
+        + "2015-05-24-12-00-00-consensus</a></td>"
+        + "<td align=\"right\">34-May-2015 12:08  </td>"
+        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
+    SortedMap<String, Long> remoteFiles =
+        new DescriptorCollectorImpl().parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNull(remoteFiles);
+  }
+
+  @Test()
+  public void testInvalidLocaleDe() {
+    String directoryListing = "<tr><td valign=\"top\">"
+        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
+        + "<a href=\"2015-05-24-12-00-00-consensus\">"
+        + "2015-05-24-12-00-00-consensus</a></td>"
+        + "<td align=\"right\">24-Mai-2015 12:08  </td>"
+        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
+    SortedMap<String, Long> remoteFiles =
+        new DescriptorCollectorImpl().parseDirectoryListing(
+        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
+    assertNull(remoteFiles);
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/ExitListImplTest.java b/src/test/java/org/torproject/descriptor/impl/ExitListImplTest.java
new file mode 100644
index 0000000..a563857
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/ExitListImplTest.java
@@ -0,0 +1,131 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ExitListEntry;
+
+public class ExitListImplTest {
+
+  @Test()
+  public void testAnnotatedInput() throws Exception {
+    ExitListImpl result = new ExitListImpl((tordnselAnnotation + input)
+        .getBytes("US-ASCII"), fileName, false);
+    assertEquals("Expected one annotation.", 1,
+        result.getAnnotations().size());
+    assertEquals(tordnselAnnotation.substring(0, 18),
+        result.getAnnotations().get(0));
+    assertEquals(1441065722000L, result.getDownloadedMillis());
+    assertTrue("Unrecognized lines: " + result.getUnrecognizedLines(),
+        result.getUnrecognizedLines().isEmpty());
+    assertEquals("Found: " + result.getExitListEntries(), 7,
+        result.getExitListEntries().size());
+    assertEquals("Found: " + result.getEntries(), 5,
+        result.getEntries().size());
+  }
+
+  @Test()
+  public void testMultipleOldExitAddresses() throws Exception {
+    ExitListImpl result = new ExitListImpl(
+        (tordnselAnnotation + multiExitAddressInput)
+        .getBytes("US-ASCII"), fileName, false);
+    assertTrue("Unrecognized lines: " + result.getUnrecognizedLines(),
+        result.getUnrecognizedLines().isEmpty());
+    assertEquals("Found: " + result.getExitListEntries(),
+        3, result.getExitListEntries().size());
+    Map<String, Long> testMap = new HashMap();
+    testMap.put("81.7.17.171", 1441044592000L);
+    testMap.put("81.7.17.172", 1441044652000L);
+    testMap.put("81.7.17.173", 1441044712000L);
+    for (ExitListEntry ele : result.getExitListEntries()) {
+      Map<String, Long> map = ele.getExitAddresses();
+      assertEquals("Found: " + map, 1, map.size());
+      Map.Entry<String, Long> ea = map.entrySet().iterator().next();
+      assertTrue("Map: " + testMap,
+          testMap.keySet().contains(ea.getKey()));
+      assertTrue("Map: " + testMap + " exitaddress: " + ea,
+          testMap.values().contains(ea.getValue()));
+      testMap.remove(ea.getKey());
+    }
+    assertTrue("Map: " + testMap, testMap.isEmpty());
+  }
+
+  @Test()
+  public void testMultipleExitAddresses() throws Exception {
+    ExitListImpl result = new ExitListImpl(
+        (tordnselAnnotation + multiExitAddressInput)
+        .getBytes("US-ASCII"), fileName, false);
+    assertTrue("Unrecognized lines: " + result.getUnrecognizedLines(),
+        result.getUnrecognizedLines().isEmpty());
+    Map<String, Long> map = result.getEntries()
+        .iterator().next().getExitAddresses();
+    assertEquals("Found: " + map, 3, map.size());
+    assertTrue("Map: " + map, map.containsKey("81.7.17.171"));
+    assertTrue("Map: " + map, map.containsKey("81.7.17.172"));
+    assertTrue("Map: " + map, map.containsKey("81.7.17.173"));
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testInsufficientInput0() throws Exception {
+    new ExitListImpl((tordnselAnnotation + insufficientInput[0])
+        .getBytes("US-ASCII"), fileName, false);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testInsufficientInput1() throws Exception {
+    new ExitListImpl((tordnselAnnotation + insufficientInput[1])
+        .getBytes("US-ASCII"), fileName, false);
+  }
+
+  private static final String tordnselAnnotation = "@type tordnsel 1.0\n";
+  private static final String fileName = "2015-09-01-00-02-02";
+  private static final String[] insufficientInput = new String[] {
+      "Downloaded 2015-09-01 00:02:02\n"
+      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
+      + "Published 2015-08-31 16:17:30\n"
+      + "LastStatus 2015-08-31 17:03:18\n",
+      "Downloaded 2015-09-01 00:02:02\n"
+      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
+      + "LastStatus 2015-08-31 17:03:18\n"
+      + "ExitAddress 81.7.17.172 2015-08-31 18:10:52\n" };
+
+  private static final String multiExitAddressInput =
+      "Downloaded 2015-09-01 00:02:02\n"
+      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
+      + "Published 2015-08-31 16:17:30\n"
+      + "LastStatus 2015-08-31 17:03:18\n"
+      + "ExitAddress 81.7.17.171 2015-08-31 18:09:52\n"
+      + "ExitAddress 81.7.17.172 2015-08-31 18:10:52\n"
+      + "ExitAddress 81.7.17.173 2015-08-31 18:11:52\n";
+  private static final String input = "Downloaded 2015-09-01 00:02:02\n"
+      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
+      + "Published 2015-08-31 16:17:30\n"
+      + "LastStatus 2015-08-31 17:03:18\n"
+      + "ExitAddress 162.247.72.201 2015-08-31 17:09:23\n"
+      + "ExitNode 0098C475875ABC4AA864738B1D1079F711C38287\n"
+      + "Published 2015-08-31 13:59:24\n"
+      + "LastStatus 2015-08-31 15:03:20\n"
+      + "ExitAddress 162.248.160.151 2015-08-31 15:07:27\n"
+      + "ExitNode 00C4B4731658D3B4987132A3F77100CFCB190D97\n"
+      + "Published 2015-08-31 17:47:52\n"
+      + "LastStatus 2015-08-31 18:03:17\n"
+      + "ExitAddress 81.7.17.171 2015-08-31 18:09:52\n"
+      + "ExitAddress 81.7.17.172 2015-08-31 18:10:52\n"
+      + "ExitAddress 81.7.17.173 2015-08-31 18:11:52\n"
+      + "ExitNode 00F2D93EBAF2F51D6EE4DCB0F37D91D72F824B16\n"
+      + "Published 2015-08-31 14:39:05\n"
+      + "LastStatus 2015-08-31 16:02:18\n"
+      + "ExitAddress 23.239.18.57 2015-08-31 16:06:07\n"
+      + "ExitNode 011B1D1E876B2C835D01FB9D407F2E00B28077F6\n"
+      + "Published 2015-08-31 05:14:35\n"
+      + "LastStatus 2015-08-31 06:03:29\n"
+      + "ExitAddress 104.131.51.150 2015-08-31 06:04:07\n";
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/ExtraInfoDescriptorImplTest.java b/src/test/java/org/torproject/descriptor/impl/ExtraInfoDescriptorImplTest.java
new file mode 100644
index 0000000..6843196
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/ExtraInfoDescriptorImplTest.java
@@ -0,0 +1,1737 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+import org.junit.Test;
+import org.torproject.descriptor.BridgeExtraInfoDescriptor;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.ExtraInfoDescriptor;
+import org.torproject.descriptor.RelayExtraInfoDescriptor;
+
+/* Test parsing of extra-info descriptors. */
+public class ExtraInfoDescriptorImplTest {
+
+  /* Helper class to build a descriptor based on default data and
+   * modifications requested by test methods. */
+  private static class DescriptorBuilder {
+    private String extraInfoLine = "extra-info chaoscomputerclub5 "
+        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26";
+    private static ExtraInfoDescriptor createWithExtraInfoLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.extraInfoLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String publishedLine = "published 2012-02-11 09:08:36";
+    private static ExtraInfoDescriptor createWithPublishedLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.publishedLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String writeHistoryLine = "write-history 2012-02-11 09:03:39 "
+        + "(900 s) 4713350144,4723824640,4710717440,4572675072";
+    private static ExtraInfoDescriptor createWithWriteHistoryLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.writeHistoryLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String readHistoryLine = "read-history 2012-02-11 09:03:39 "
+        + "(900 s) 4707695616,4699666432,4650004480,4489718784";
+    private static ExtraInfoDescriptor createWithReadHistoryLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.readHistoryLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String dirreqWriteHistoryLine = "dirreq-write-history "
+        + "2012-02-11 09:03:39 (900 s) 81281024,64996352,60625920,"
+        + "67922944";
+    private static ExtraInfoDescriptor createWithDirreqWriteHistoryLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.dirreqWriteHistoryLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String dirreqReadHistoryLine = "dirreq-read-history "
+        + "2012-02-11 09:03:39 (900 s) 17074176,16235520,16005120,"
+        + "16209920";
+    private static ExtraInfoDescriptor createWithDirreqReadHistoryLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.dirreqReadHistoryLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String geoipDbDigestLine = null;
+    private static ExtraInfoDescriptor createWithGeoipDbDigestLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.geoipDbDigestLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String geoip6DbDigestLine = null;
+    private static ExtraInfoDescriptor createWithGeoip6DbDigestLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.geoip6DbDigestLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String geoipStatsLines = null;
+    private static ExtraInfoDescriptor createWithGeoipStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.geoipStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String dirreqStatsLines = null;
+    private static ExtraInfoDescriptor createWithDirreqStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.dirreqStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String entryStatsLines = null;
+    private static ExtraInfoDescriptor createWithEntryStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.entryStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String cellStatsLines = null;
+    private static ExtraInfoDescriptor createWithCellStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.cellStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String connBiDirectLine = null;
+    private static ExtraInfoDescriptor createWithConnBiDirectLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.connBiDirectLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String exitStatsLines = null;
+    private static ExtraInfoDescriptor createWithExitStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.exitStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String bridgeStatsLines = null;
+    private static ExtraInfoDescriptor createWithBridgeStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.bridgeStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String hidservStatsLines = null;
+    private static ExtraInfoDescriptor createWithHidservStatsLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.hidservStatsLines = lines;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String unrecognizedLine = null;
+    private static ExtraInfoDescriptor createWithUnrecognizedLine(
+        String line, boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.unrecognizedLine = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(),
+          failUnrecognizedDescriptorLines);
+    }
+    private byte[] nonAsciiLineBytes = null;
+    private static ExtraInfoDescriptor createWithNonAsciiLineBytes(
+        byte[] lineBytes, boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.nonAsciiLineBytes = lineBytes;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(),
+          failUnrecognizedDescriptorLines);
+    }
+    private String routerSignatureLines = "router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
+        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
+        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
+        + "-----END SIGNATURE-----";
+    private static ExtraInfoDescriptor createWithRouterSignatureLines(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.routerSignatureLines = line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String identityEd25519Lines = null,
+        masterKeyEd25519Line = null, routerSigEd25519Line = null;
+    private static ExtraInfoDescriptor createWithEd25519Lines(
+        String identityEd25519Lines, String masterKeyEd25519Line,
+        String routerSigEd25519Line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.identityEd25519Lines = identityEd25519Lines;
+      db.masterKeyEd25519Line = masterKeyEd25519Line;
+      db.routerSigEd25519Line = routerSigEd25519Line;
+      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private byte[] buildDescriptor() {
+      StringBuilder sb = new StringBuilder();
+      if (this.extraInfoLine != null) {
+        sb.append(this.extraInfoLine).append("\n");
+      }
+      if (this.identityEd25519Lines != null) {
+        sb.append(this.identityEd25519Lines).append("\n");
+      }
+      if (this.masterKeyEd25519Line != null) {
+        sb.append(this.masterKeyEd25519Line).append("\n");
+      }
+      if (this.publishedLine != null) {
+        sb.append(this.publishedLine).append("\n");
+      }
+      if (this.writeHistoryLine != null) {
+        sb.append(this.writeHistoryLine).append("\n");
+      }
+      if (this.readHistoryLine != null) {
+        sb.append(this.readHistoryLine).append("\n");
+      }
+      if (this.dirreqWriteHistoryLine != null) {
+        sb.append(this.dirreqWriteHistoryLine).append("\n");
+      }
+      if (this.dirreqReadHistoryLine != null) {
+        sb.append(this.dirreqReadHistoryLine).append("\n");
+      }
+      if (this.geoipDbDigestLine != null) {
+        sb.append(this.geoipDbDigestLine).append("\n");
+      }
+      if (this.geoip6DbDigestLine != null) {
+        sb.append(this.geoip6DbDigestLine).append("\n");
+      }
+      if (this.geoipStatsLines != null) {
+        sb.append(this.geoipStatsLines).append("\n");
+      }
+      if (this.dirreqStatsLines != null) {
+        sb.append(this.dirreqStatsLines).append("\n");
+      }
+      if (this.entryStatsLines != null) {
+        sb.append(this.entryStatsLines).append("\n");
+      }
+      if (this.cellStatsLines != null) {
+        sb.append(this.cellStatsLines).append("\n");
+      }
+      if (this.connBiDirectLine != null) {
+        sb.append(this.connBiDirectLine).append("\n");
+      }
+      if (this.exitStatsLines != null) {
+        sb.append(this.exitStatsLines).append("\n");
+      }
+      if (this.bridgeStatsLines != null) {
+        sb.append(this.bridgeStatsLines).append("\n");
+      }
+      if (this.hidservStatsLines != null) {
+        sb.append(this.hidservStatsLines).append("\n");
+      }
+      if (this.unrecognizedLine != null) {
+        sb.append(this.unrecognizedLine).append("\n");
+      }
+      if (this.nonAsciiLineBytes != null) {
+        try {
+          ByteArrayOutputStream baos = new ByteArrayOutputStream();
+          baos.write(sb.toString().getBytes());
+          baos.write(this.nonAsciiLineBytes);
+          baos.write("\n".getBytes());
+          if (this.routerSignatureLines != null) {
+            baos.write(this.routerSignatureLines.getBytes());
+          }
+          return baos.toByteArray();
+        } catch (IOException e) {
+          return null;
+        }
+      }
+      if (this.routerSigEd25519Line != null) {
+        sb.append(this.routerSigEd25519Line).append("\n");
+      }
+      if (this.routerSignatureLines != null) {
+        sb.append(this.routerSignatureLines).append("\n");
+      }
+      return sb.toString().getBytes();
+    }
+  }
+
+  /* Helper class to build a set of geoip-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class GeoipStatsBuilder {
+    private String geoipStartTimeLine = "geoip-start-time 2012-02-10 "
+        + "18:32:51";
+    private static ExtraInfoDescriptor createWithGeoipStartTimeLine(
+        String line) throws DescriptorParseException {
+      GeoipStatsBuilder gsb = new GeoipStatsBuilder();
+      gsb.geoipStartTimeLine = line;
+      return DescriptorBuilder.createWithGeoipStatsLines(
+          gsb.buildGeoipStatsLines());
+    }
+    private String geoipClientOriginsLine = "geoip-client-origins "
+        + "de=1152,cn=896,us=712,it=504,ru=352,fr=208,gb=208,ir=200";
+    private static ExtraInfoDescriptor createWithGeoipClientOriginsLine(
+        String line) throws DescriptorParseException {
+      GeoipStatsBuilder gsb = new GeoipStatsBuilder();
+      gsb.geoipClientOriginsLine = line;
+      return DescriptorBuilder.createWithGeoipStatsLines(
+          gsb.buildGeoipStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithGeoipStatsLines(
+          new GeoipStatsBuilder().buildGeoipStatsLines());
+    }
+    private String buildGeoipStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.geoipStartTimeLine != null) {
+        sb.append(this.geoipStartTimeLine).append("\n");
+      }
+      if (this.geoipClientOriginsLine != null) {
+        sb.append(this.geoipClientOriginsLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  /* Helper class to build a set of dirreq-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class DirreqStatsBuilder {
+    private String dirreqStatsEndLine = "dirreq-stats-end 2012-02-11 "
+        + "00:59:53 (86400 s)";
+    private static ExtraInfoDescriptor createWithDirreqStatsEndLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqStatsEndLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV3IpsLine = "dirreq-v3-ips us=1544,de=1056,"
+        + "it=1032,fr=784,es=640,ru=440,br=312,gb=272,kr=224,sy=192";
+    private static ExtraInfoDescriptor createWithDirreqV3IpsLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV3IpsLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV2IpsLine = "dirreq-v2-ips ";
+    private static ExtraInfoDescriptor createWithDirreqV2IpsLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV2IpsLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV3ReqsLine = "dirreq-v3-reqs us=1744,de=1224,"
+        + "it=1080,fr=832,es=664,ru=536,br=344,gb=296,kr=272,in=216";
+    private static ExtraInfoDescriptor createWithDirreqV3ReqsLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV3ReqsLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV2ReqsLine = "dirreq-v2-reqs ";
+    private static ExtraInfoDescriptor createWithDirreqV2ReqsLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV2ReqsLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV3RespLine = "dirreq-v3-resp ok=10848,"
+        + "not-enough-sigs=8,unavailable=0,not-found=0,not-modified=0,"
+        + "busy=80";
+    private static ExtraInfoDescriptor createWithDirreqV3RespLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV3RespLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV2RespLine = "dirreq-v2-resp ok=0,unavailable=0,"
+        + "not-found=1576,not-modified=0,busy=0";
+    private static ExtraInfoDescriptor createWithDirreqV2RespLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV2RespLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV2ShareLine = "dirreq-v2-share 0.37%";
+    private static ExtraInfoDescriptor createWithDirreqV2ShareLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV2ShareLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV3ShareLine = "dirreq-v3-share 0.37%";
+    private static ExtraInfoDescriptor createWithDirreqV3ShareLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV3ShareLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV3DirectDlLine = "dirreq-v3-direct-dl "
+        + "complete=36,timeout=4,running=0,min=7538,d1=20224,d2=28950,"
+        + "q1=40969,d3=55786,d4=145813,md=199164,d6=267230,d7=480900,"
+        + "q3=481049,d8=531276,d9=778086,max=15079428";
+    private static ExtraInfoDescriptor createWithDirreqV3DirectDlLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV3DirectDlLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV2DirectDlLine = "dirreq-v2-direct-dl "
+        + "complete=0,timeout=0,running=0";
+    private static ExtraInfoDescriptor createWithDirreqV2DirectDlLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV2DirectDlLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV3TunneledDlLine = "dirreq-v3-tunneled-dl "
+        + "complete=10608,timeout=204,running=4,min=507,d1=20399,"
+        + "d2=27588,q1=29292,d3=30889,d4=40624,md=59967,d6=103333,"
+        + "d7=161170,q3=209415,d8=256711,d9=452503,max=23417777";
+    private static ExtraInfoDescriptor createWithDirreqV3TunneledDlLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV3TunneledDlLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private String dirreqV2TunneledDlLine = "dirreq-v2-tunneled-dl "
+        + "complete=0,timeout=0,running=0";
+    private static ExtraInfoDescriptor createWithDirreqV2TunneledDlLine(
+        String line) throws DescriptorParseException {
+      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
+      dsb.dirreqV2TunneledDlLine = line;
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          dsb.buildDirreqStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithDirreqStatsLines(
+          new DirreqStatsBuilder().buildDirreqStatsLines());
+    }
+    private String buildDirreqStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.dirreqStatsEndLine != null) {
+        sb.append(this.dirreqStatsEndLine).append("\n");
+      }
+      if (this.dirreqV3IpsLine != null) {
+        sb.append(this.dirreqV3IpsLine).append("\n");
+      }
+      if (this.dirreqV2IpsLine != null) {
+        sb.append(this.dirreqV2IpsLine).append("\n");
+      }
+      if (this.dirreqV3ReqsLine != null) {
+        sb.append(this.dirreqV3ReqsLine).append("\n");
+      }
+      if (this.dirreqV2ReqsLine != null) {
+        sb.append(this.dirreqV2ReqsLine).append("\n");
+      }
+      if (this.dirreqV3RespLine != null) {
+        sb.append(this.dirreqV3RespLine).append("\n");
+      }
+      if (this.dirreqV2RespLine != null) {
+        sb.append(this.dirreqV2RespLine).append("\n");
+      }
+      if (this.dirreqV2ShareLine != null) {
+        sb.append(this.dirreqV2ShareLine).append("\n");
+      }
+      if (this.dirreqV3ShareLine != null) {
+        sb.append(this.dirreqV3ShareLine).append("\n");
+      }
+      if (this.dirreqV3DirectDlLine != null) {
+        sb.append(this.dirreqV3DirectDlLine).append("\n");
+      }
+      if (this.dirreqV2DirectDlLine != null) {
+        sb.append(this.dirreqV2DirectDlLine).append("\n");
+      }
+      if (this.dirreqV3TunneledDlLine != null) {
+        sb.append(this.dirreqV3TunneledDlLine).append("\n");
+      }
+      if (this.dirreqV2TunneledDlLine != null) {
+        sb.append(this.dirreqV2TunneledDlLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  /* Helper class to build a set of entry-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class EntryStatsBuilder {
+    private String entryStatsEndLine = "entry-stats-end 2012-02-11 "
+        + "01:59:39 (86400 s)";
+    private static ExtraInfoDescriptor createWithEntryStatsEndLine(
+        String line) throws DescriptorParseException {
+      EntryStatsBuilder esb = new EntryStatsBuilder();
+      esb.entryStatsEndLine = line;
+      return DescriptorBuilder.createWithEntryStatsLines(
+          esb.buildEntryStatsLines());
+    }
+    private String entryIpsLine = "entry-ips ir=25368,us=15744,it=14816,"
+        + "de=13256,es=8280,fr=8120,br=5176,sy=4760,ru=4504,sa=4216,"
+        + "gb=3152,pl=2928,nl=2208,kr=1856,ca=1792,ua=1272,in=1192";
+    private static ExtraInfoDescriptor createWithEntryIpsLine(
+        String line) throws DescriptorParseException {
+      EntryStatsBuilder esb = new EntryStatsBuilder();
+      esb.entryIpsLine = line;
+      return DescriptorBuilder.createWithEntryStatsLines(
+          esb.buildEntryStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithEntryStatsLines(
+          new EntryStatsBuilder().buildEntryStatsLines());
+    }
+    private String buildEntryStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.entryStatsEndLine != null) {
+        sb.append(this.entryStatsEndLine).append("\n");
+      }
+      if (this.entryIpsLine != null) {
+        sb.append(this.entryIpsLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  /* Helper class to build a set of cell-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class CellStatsBuilder {
+    private String cellStatsEndLine = "cell-stats-end 2012-02-11 "
+        + "01:59:39 (86400 s)";
+    private static ExtraInfoDescriptor createWithCellStatsEndLine(
+        String line) throws DescriptorParseException {
+      CellStatsBuilder csb = new CellStatsBuilder();
+      csb.cellStatsEndLine = line;
+      return DescriptorBuilder.createWithCellStatsLines(
+          csb.buildCellStatsLines());
+    }
+    private String cellProcessedCellsLine = "cell-processed-cells "
+        + "1441,11,6,4,2,1,1,1,1,1";
+    private static ExtraInfoDescriptor createWithCellProcessedCellsLine(
+        String line) throws DescriptorParseException {
+      CellStatsBuilder csb = new CellStatsBuilder();
+      csb.cellProcessedCellsLine = line;
+      return DescriptorBuilder.createWithCellStatsLines(
+          csb.buildCellStatsLines());
+    }
+    private String cellQueuedCellsLine = "cell-queued-cells "
+        + "3.29,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00";
+    private static ExtraInfoDescriptor createWithCellQueuedCellsLine(
+        String line) throws DescriptorParseException {
+      CellStatsBuilder csb = new CellStatsBuilder();
+      csb.cellQueuedCellsLine = line;
+      return DescriptorBuilder.createWithCellStatsLines(
+          csb.buildCellStatsLines());
+    }
+    private String cellTimeInQueueLine = "cell-time-in-queue "
+        + "524,1,1,0,0,25,0,0,0,0";
+    private static ExtraInfoDescriptor createWithCellTimeInQueueLine(
+        String line) throws DescriptorParseException {
+      CellStatsBuilder csb = new CellStatsBuilder();
+      csb.cellTimeInQueueLine = line;
+      return DescriptorBuilder.createWithCellStatsLines(
+          csb.buildCellStatsLines());
+    }
+    private String cellCircuitsPerDecileLine = "cell-circuits-per-decile "
+        + "866";
+    private static ExtraInfoDescriptor
+        createWithCellCircuitsPerDecileLine(String line)
+        throws DescriptorParseException {
+      CellStatsBuilder csb = new CellStatsBuilder();
+      csb.cellCircuitsPerDecileLine = line;
+      return DescriptorBuilder.createWithCellStatsLines(
+          csb.buildCellStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithCellStatsLines(
+          new CellStatsBuilder().buildCellStatsLines());
+    }
+    private String buildCellStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.cellStatsEndLine != null) {
+        sb.append(this.cellStatsEndLine).append("\n");
+      }
+      if (this.cellProcessedCellsLine != null) {
+        sb.append(this.cellProcessedCellsLine).append("\n");
+      }
+      if (this.cellQueuedCellsLine != null) {
+        sb.append(this.cellQueuedCellsLine).append("\n");
+      }
+      if (this.cellTimeInQueueLine != null) {
+        sb.append(this.cellTimeInQueueLine).append("\n");
+      }
+      if (this.cellCircuitsPerDecileLine != null) {
+        sb.append(this.cellCircuitsPerDecileLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  /* Helper class to build a set of exit-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class ExitStatsBuilder {
+    private String exitStatsEndLine = "exit-stats-end 2012-02-11 "
+        + "01:59:39 (86400 s)";
+    private static ExtraInfoDescriptor createWithExitStatsEndLine(
+        String line) throws DescriptorParseException {
+      ExitStatsBuilder esb = new ExitStatsBuilder();
+      esb.exitStatsEndLine = line;
+      return DescriptorBuilder.createWithExitStatsLines(
+          esb.buildExitStatsLines());
+    }
+    private String exitKibibytesWrittenLine = "exit-kibibytes-written "
+        + "25=74647,80=31370,443=20577,49755=23,52563=12,52596=1111,"
+        + "57528=4,60912=11,61351=6,64811=3365,other=2592";
+    private static ExtraInfoDescriptor createWithExitKibibytesWrittenLine(
+        String line) throws DescriptorParseException {
+      ExitStatsBuilder esb = new ExitStatsBuilder();
+      esb.exitKibibytesWrittenLine = line;
+      return DescriptorBuilder.createWithExitStatsLines(
+          esb.buildExitStatsLines());
+    }
+    private String exitKibibytesReadLine = "exit-kibibytes-read "
+        + "25=35562,80=1254256,443=110279,49755=9396,52563=1911,"
+        + "52596=648,57528=1188,60912=1427,61351=1824,64811=14,"
+        + "other=3054";
+    private static ExtraInfoDescriptor createWithExitKibibytesReadLine(
+        String line) throws DescriptorParseException {
+      ExitStatsBuilder esb = new ExitStatsBuilder();
+      esb.exitKibibytesReadLine = line;
+      return DescriptorBuilder.createWithExitStatsLines(
+          esb.buildExitStatsLines());
+    }
+    private String exitStreamsOpenedLine = "exit-streams-opened "
+        + "25=369748,80=64212,443=151660,49755=4,52563=4,52596=4,57528=4,"
+        + "60912=4,61351=4,64811=4,other=1212";
+    private static ExtraInfoDescriptor createWithExitStreamsOpenedLine(
+        String line) throws DescriptorParseException {
+      ExitStatsBuilder esb = new ExitStatsBuilder();
+      esb.exitStreamsOpenedLine = line;
+      return DescriptorBuilder.createWithExitStatsLines(
+          esb.buildExitStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithExitStatsLines(
+          new ExitStatsBuilder().buildExitStatsLines());
+    }
+    private String buildExitStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.exitStatsEndLine != null) {
+        sb.append(this.exitStatsEndLine).append("\n");
+      }
+      if (this.exitKibibytesWrittenLine != null) {
+        sb.append(this.exitKibibytesWrittenLine).append("\n");
+      }
+      if (this.exitKibibytesReadLine != null) {
+        sb.append(this.exitKibibytesReadLine).append("\n");
+      }
+      if (this.exitStreamsOpenedLine != null) {
+        sb.append(this.exitStreamsOpenedLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  /* Helper class to build a set of bridge-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class BridgeStatsBuilder {
+    private String bridgeStatsEndLine = "bridge-stats-end 2012-02-11 "
+        + "01:59:39 (86400 s)";
+    private static ExtraInfoDescriptor createWithBridgeStatsEndLine(
+        String line) throws DescriptorParseException {
+      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
+      bsb.bridgeStatsEndLine = line;
+      return DescriptorBuilder.createWithBridgeStatsLines(
+          bsb.buildBridgeStatsLines());
+    }
+    private String bridgeIpsLine = "bridge-ips ir=24,sy=16,??=8,cn=8,"
+        + "de=8,es=8,fr=8,gb=8,in=8,jp=8,kz=8,nl=8,ua=8,us=8,vn=8,za=8";
+    private static ExtraInfoDescriptor createWithBridgeIpsLine(
+        String line) throws DescriptorParseException {
+      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
+      bsb.bridgeIpsLine = line;
+      return DescriptorBuilder.createWithBridgeStatsLines(
+          bsb.buildBridgeStatsLines());
+    }
+    private String bridgeIpVersionsLine = "bridge-ip-versions v4=8,v6=16";
+    private static ExtraInfoDescriptor createWithBridgeIpVersionsLine(
+        String line) throws DescriptorParseException {
+      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
+      bsb.bridgeIpVersionsLine = line;
+      return DescriptorBuilder.createWithBridgeStatsLines(
+          bsb.buildBridgeStatsLines());
+    }
+    private String bridgeIpTransportsLine = "bridge-ip-transports "
+        + "<OR>=8,obfs2=792,obfs3=1728";
+    private static ExtraInfoDescriptor createWithBridgeIpTransportsLine(
+        String line) throws DescriptorParseException {
+      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
+      bsb.bridgeIpTransportsLine = line;
+      return DescriptorBuilder.createWithBridgeStatsLines(
+          bsb.buildBridgeStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithBridgeStatsLines(
+          new BridgeStatsBuilder().buildBridgeStatsLines());
+    }
+    private String buildBridgeStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.bridgeStatsEndLine != null) {
+        sb.append(this.bridgeStatsEndLine).append("\n");
+      }
+      if (this.bridgeIpsLine != null) {
+        sb.append(this.bridgeIpsLine).append("\n");
+      }
+      if (this.bridgeIpVersionsLine != null) {
+        sb.append(this.bridgeIpVersionsLine).append("\n");
+      }
+      if (this.bridgeIpTransportsLine != null) {
+        sb.append(this.bridgeIpTransportsLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  /* Helper class to build a set of hidserv-stats lines based on default
+   * data and modifications requested by test methods. */
+  private static class HidservStatsBuilder {
+    private String hidservStatsEndLine = "hidserv-stats-end 2015-12-03 "
+        + "14:26:56 (86400 s)";
+    private static ExtraInfoDescriptor createWithHidservStatsEndLine(
+        String line) throws DescriptorParseException {
+      HidservStatsBuilder hsb = new HidservStatsBuilder();
+      hsb.hidservStatsEndLine = line;
+      return DescriptorBuilder.createWithHidservStatsLines(
+          hsb.buildHidservStatsLines());
+    }
+    private String hidservRendRelayedCellsLine =
+        "hidserv-rend-relayed-cells 36474281 delta_f=2048 epsilon=0.30 "
+        + "bin_size=1024";
+    private static ExtraInfoDescriptor
+        createWithHidservRendRelayedCellsLine(String line)
+        throws DescriptorParseException {
+      HidservStatsBuilder hsb = new HidservStatsBuilder();
+      hsb.hidservRendRelayedCellsLine = line;
+      return DescriptorBuilder.createWithHidservStatsLines(
+          hsb.buildHidservStatsLines());
+    }
+    private String hidservDirOnionsSeenLine = "hidserv-dir-onions-seen "
+        + "-3 delta_f=8 epsilon=0.30 bin_size=8";
+    private static ExtraInfoDescriptor createWithHidservDirOnionsSeenLine(
+        String line) throws DescriptorParseException {
+      HidservStatsBuilder hsb = new HidservStatsBuilder();
+      hsb.hidservDirOnionsSeenLine = line;
+      return DescriptorBuilder.createWithHidservStatsLines(
+          hsb.buildHidservStatsLines());
+    }
+    private static ExtraInfoDescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      return DescriptorBuilder.createWithHidservStatsLines(
+          new HidservStatsBuilder().buildHidservStatsLines());
+    }
+    private String buildHidservStatsLines() {
+      StringBuilder sb = new StringBuilder();
+      if (this.hidservStatsEndLine != null) {
+        sb.append(this.hidservStatsEndLine).append("\n");
+      }
+      if (this.hidservRendRelayedCellsLine != null) {
+        sb.append(this.hidservRendRelayedCellsLine).append("\n");
+      }
+      if (this.hidservDirOnionsSeenLine != null) {
+        sb.append(this.hidservDirOnionsSeenLine).append("\n");
+      }
+      String lines = sb.toString();
+      if (lines.endsWith("\n")) {
+        lines = lines.substring(0, lines.length() - 1);
+      }
+      return lines;
+    }
+  }
+
+  @Test()
+  public void testSampleDescriptor() throws DescriptorParseException {
+    DescriptorBuilder db = new DescriptorBuilder();
+    ExtraInfoDescriptor descriptor =
+        new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
+    assertEquals("chaoscomputerclub5", descriptor.getNickname());
+    assertEquals("A9C039A5FD02FCA06303DCFAABE25C5912C63B26",
+        descriptor.getFingerprint());
+    assertEquals(1328951316000L, descriptor.getPublishedMillis());
+    assertNotNull(descriptor.getWriteHistory());
+    assertEquals(1328951019000L, descriptor.getWriteHistory().
+        getHistoryEndMillis());
+    assertEquals(900L, descriptor.getWriteHistory().getIntervalLength());
+    assertEquals(4572675072L, (long) descriptor.getWriteHistory().
+        getBandwidthValues().get(1328951019000L));
+    assertNotNull(descriptor.getReadHistory());
+    assertNotNull(descriptor.getDirreqWriteHistory());
+    assertNotNull(descriptor.getDirreqReadHistory());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExtraInfoLineMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine(null);
+  }
+
+  @Test()
+  public void testExtraInfoOpt() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithExtraInfoLine("opt extra-info chaoscomputerclub5 "
+        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+    assertEquals("chaoscomputerclub5", descriptor.getNickname());
+    assertEquals("A9C039A5FD02FCA06303DCFAABE25C5912C63B26",
+        descriptor.getFingerprint());
+  }
+
+  @Test()
+  public void testExtraInfoNicknameTwoSpaces()
+      throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithExtraInfoLine("opt extra-info chaoscomputerclub5  "
+        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+    assertEquals("chaoscomputerclub5", descriptor.getNickname());
+    assertEquals("A9C039A5FD02FCA06303DCFAABE25C5912C63B26",
+        descriptor.getFingerprint());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExtraInfoLineNotFirst()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("geoip-db-digest "
+        + "916A3CA8B7DF61473D5AE5B21711F35F301CE9E8\n"
+        + "extra-info chaoscomputerclub5 "
+        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("extra-info  "
+        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameInvalidChar() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("extra-info "
+        + "chaoscomputerclub% A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameTooLong() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("extra-info "
+        + "chaoscomputerclub5ReallyLongNickname "
+        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintG() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("extra-info "
+        + "chaoscomputerclub5 G9C039A5FD02FCA06303DCFAABE25C5912C63B26");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooShort() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("extra-info "
+        + "chaoscomputerclub5 A9C039A5FD02FCA06303DCFAABE25C5912C6");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooLong() throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoLine("extra-info "
+        + "chaoscomputerclub5 A9C039A5FD02FCA06303DCFAABE25C5912C63B26"
+        + "A9C0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublishedMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithPublishedLine(null);
+  }
+
+  @Test()
+  public void testPublishedOpt() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithPublishedLine("opt published 2012-02-11 09:08:36");
+    assertEquals(1328951316000L, descriptor.getPublishedMillis());
+  }
+
+  @Test()
+  public void testPublishedMillis() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithPublishedLine("opt published 2012-02-11 09:08:36.123");
+    assertEquals(1328951316000L, descriptor.getPublishedMillis());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryNegativeBytes()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-02-11 09:03:39 (900 s) "
+        + "-4713350144,-4723824640,-4710717440,-4572675072");
+  }
+
+  @Test()
+  public void testReadHistoryTabInterval()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithReadHistoryLine("read-history "
+        + "2012-02-11 09:03:39 (900\ts) "
+        + "4707695616,4699666432,4650004480,4489718784");
+  }
+
+  @Test()
+  public void testReadHistoryTabIntervalBytes()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithReadHistoryLine("read-history "
+        + "2012-02-11 09:03:39 (900 s)\t"
+        + "4707695616,4699666432,4650004480,4489718784");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testReadHistoryNegativeInterval()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithReadHistoryLine("read-history "
+        + "2012-02-11 09:03:39 (-900 s) "
+        + "4707695616,4699666432,4650004480,4489718784");
+  }
+
+  @Test()
+  public void testReadHistoryNonStandardInterval()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithReadHistoryLine("read-history "
+        + "2012-02-11 09:03:39 (1800 s) "
+        + "4707695616,4699666432,4650004480,4489718784");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqWriteHistoryMissingBytesBegin()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithDirreqWriteHistoryLine(
+        "dirreq-write-history 2012-02-11 09:03:39 (900 s) "
+        + ",64996352,60625920,67922944");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqWriteHistoryMissingBytesMiddle()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithDirreqWriteHistoryLine(
+        "dirreq-write-history 2012-02-11 09:03:39 (900 s) "
+        + "81281024,,60625920,67922944");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqReadHistoryMissingBytesEnd()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithDirreqReadHistoryLine(
+        "dirreq-read-history 2012-02-11 09:03:39 (900 s) "
+        + "17074176,16235520,16005120,");
+  }
+
+  @Test()
+  public void testGeoipDbDigestValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithGeoipDbDigestLine("geoip-db-digest "
+        + "916A3CA8B7DF61473D5AE5B21711F35F301CE9E8");
+    assertEquals("916A3CA8B7DF61473D5AE5B21711F35F301CE9E8",
+        descriptor.getGeoipDbDigest());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipDbDigestTooShort()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithGeoipDbDigestLine("geoip-db-digest "
+        + "916A3CA8B7DF61473D5AE5B21711F35F301C");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipDbDigestIllegalChars()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithGeoipDbDigestLine("geoip-db-digest "
+        + "&%6A3CA8B7DF61473D5AE5B21711F35F301CE9E8");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipDbDigestMissing()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithGeoipDbDigestLine("geoip-db-digest");
+  }
+
+  @Test()
+  public void testGeoip6DbDigestValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithGeoip6DbDigestLine("geoip6-db-digest "
+        + "916A3CA8B7DF61473D5AE5B21711F35F301CE9E8");
+    assertEquals("916A3CA8B7DF61473D5AE5B21711F35F301CE9E8",
+        descriptor.getGeoip6DbDigest());
+  }
+
+  @Test()
+  public void testGeoipStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = GeoipStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1328898771000L, descriptor.getGeoipStartTimeMillis());
+    SortedMap<String, Integer> ips = descriptor.getGeoipClientOrigins();
+    assertNotNull(ips);
+    assertEquals(1152, ips.get("de").intValue());
+    assertEquals(896, ips.get("cn").intValue());
+    assertFalse(ips.containsKey("pl"));
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipStartTimeDateOnly()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipStartTimeLine("geoip-start-time "
+        + "2012-02-10");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsDash()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de-1152,cn=896,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,ir=200");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsZero()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=zero,cn=896,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,ir=200");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsNone()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=none,cn=896,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,ir=200");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsOther()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=1152,cn=896,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,other=200");
+  }
+
+  @Test()
+  public void testGeoipClientOriginsQuestionMarks()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=1152,cn=896,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,??=200");
+  }
+
+  @Test()
+  public void testGeoipClientOriginsCapital()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins DE=1152,CN=896,US=712,IT=504,RU=352,FR=208,"
+        + "GB=208,IR=200");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsMissingBegin()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins ,cn=896,us=712,it=504,ru=352,fr=208,gb=208,"
+        + "ir=200");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsMissingMiddle()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=1152,,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,ir=200");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testGeoipClientOriginsMissingEnd()
+      throws DescriptorParseException {
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=1152,cn=896,us=712,it=504,ru=352,fr=208,"
+        + "gb=208,");
+  }
+
+  @Test()
+  public void testGeoipClientOriginsDuplicate()
+      throws DescriptorParseException {
+    /* dir-spec.txt doesn't say anything about duplicate country codes, so
+     * this line is valid, even though it leads to a somewhat undefined
+     * parse result. */
+    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
+        "geoip-client-origins de=1152,de=952,cn=896,us=712,it=504,"
+        + "ru=352,fr=208,gb=208,ir=200");
+  }
+
+  @Test()
+  public void testDirreqStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DirreqStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1328921993000L, descriptor.getDirreqStatsEndMillis());
+    assertEquals(86400L, descriptor.getDirreqStatsIntervalLength());
+    SortedMap<String, Integer> ips = descriptor.getDirreqV3Ips();
+    assertNotNull(ips);
+    assertEquals(1544, ips.get("us").intValue());
+    assertFalse(ips.containsKey("no"));
+    assertTrue(descriptor.getDirreqV2Ips().isEmpty());
+    SortedMap<String, Integer> reqs = descriptor.getDirreqV3Reqs();
+    assertEquals(832, reqs.get("fr").intValue());
+    assertTrue(descriptor.getDirreqV2Reqs().isEmpty());
+    SortedMap<String, Integer> resp = descriptor.getDirreqV3Resp();
+    assertEquals(10848, resp.get("ok").intValue());
+    assertEquals(8, resp.get("not-enough-sigs").intValue());
+    resp = descriptor.getDirreqV2Resp();
+    assertEquals(1576, resp.get("not-found").intValue());
+    assertEquals(0.37, descriptor.getDirreqV2Share(), 0.0001);
+    assertEquals(0.37, descriptor.getDirreqV3Share(), 0.0001);
+    SortedMap<String, Integer> dl = descriptor.getDirreqV3DirectDl();
+    assertEquals(36, dl.get("complete").intValue());
+    dl = descriptor.getDirreqV2DirectDl();
+    assertEquals(0, dl.get("timeout").intValue());
+    dl = descriptor.getDirreqV3TunneledDl();
+    assertEquals(10608, dl.get("complete").intValue());
+    dl = descriptor.getDirreqV2TunneledDl();
+    assertEquals(0, dl.get("complete").intValue());
+  }
+
+  @Test()
+  public void testDirreqStatsIntervalTwoDays()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqStatsEndLine("dirreq-stats-end "
+        + "2012-02-11 00:59:53 (172800 s)");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3IpsThreeLetterCountry()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV3IpsLine("dirreq-v3-ips "
+        + "usa=1544");
+  }
+
+  @Test()
+  public void testDirreqV2IpsDigitCountry()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV2IpsLine("dirreq-v2-ips 00=8");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3ReqsOneLetterCountry()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV3ReqsLine("dirreq-v3-reqs "
+        + "u=1744");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV2ReqsNoNumber()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV2ReqsLine("dirreq-v2-reqs us=");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3RespTwoEqualSigns()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV3RespLine("dirreq-v3-resp "
+        + "ok==10848");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV2RespNull()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV2RespLine("dirreq-v2-resp "
+        + "ok=null");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV2ShareComma()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV2ShareLine("dirreq-v2-share "
+        + "0,37%");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3ShareNoPercent()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV3ShareLine("dirreq-v3-share "
+        + "0.37");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3DirectDlSpace()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV3DirectDlLine(
+        "dirreq-v3-direct-dl complete 36");
+  }
+
+  @Test()
+  public void testDirreqV2DirectDlNegative()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV2DirectDlLine(
+        "dirreq-v2-direct-dl complete=-8");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3TunneledDlTooLarge()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV3TunneledDlLine(
+        "dirreq-v3-tunneled-dl complete=2147483648");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirreqV3TunneledDlDouble()
+      throws DescriptorParseException {
+    DirreqStatsBuilder.createWithDirreqV2TunneledDlLine(
+        "dirreq-v2-tunneled-dl complete=0.001");
+  }
+
+  @Test()
+  public void testEntryStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = EntryStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1328925579000L, descriptor.getEntryStatsEndMillis());
+    assertEquals(86400L, descriptor.getEntryStatsIntervalLength());
+    SortedMap<String, Integer> ips = descriptor.getEntryIps();
+    assertNotNull(ips);
+    assertEquals(25368, ips.get("ir").intValue());
+    assertFalse(ips.containsKey("no"));
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEntryStatsEndNoDate() throws DescriptorParseException {
+    EntryStatsBuilder.createWithEntryStatsEndLine("entry-stats-end "
+        + "01:59:39 (86400 s)");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEntryStatsIpsSemicolon()
+      throws DescriptorParseException {
+    EntryStatsBuilder.createWithEntryIpsLine("entry-ips "
+        + "ir=25368;us=15744");
+  }
+
+  @Test()
+  public void testCellStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = CellStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1328925579000L, descriptor.getCellStatsEndMillis());
+    assertEquals(86400L, descriptor.getCellStatsIntervalLength());
+    List<Integer> processedCells = descriptor.getCellProcessedCells();
+    assertEquals(10, processedCells.size());
+    assertEquals(1441, processedCells.get(0).intValue());
+    assertEquals(11, processedCells.get(1).intValue());
+    List<Double> queuedCells = descriptor.getCellQueuedCells();
+    assertEquals(10, queuedCells.size());
+    assertEquals(3.29, queuedCells.get(0), 0.001);
+    assertEquals(0.00, queuedCells.get(1), 0.001);
+    List<Integer> timeInQueue = descriptor.getCellTimeInQueue();
+    assertEquals(10, timeInQueue.size());
+    assertEquals(524, timeInQueue.get(0).intValue());
+    assertEquals(1, timeInQueue.get(1).intValue());
+    assertEquals(866, descriptor.getCellCircuitsPerDecile());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testCellStatsEndNoSeconds()
+      throws DescriptorParseException {
+    CellStatsBuilder.createWithCellStatsEndLine("cell-stats-end "
+        + "2012-02-11 01:59:39 (86400)");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testCellProcessedCellsNineComma()
+      throws DescriptorParseException {
+    CellStatsBuilder.createWithCellProcessedCellsLine(
+        "cell-processed-cells 1441,11,6,4,2,1,1,1,1,");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testCellProcessedCellsEleven()
+      throws DescriptorParseException {
+    CellStatsBuilder.createWithCellQueuedCellsLine("cell-queued-cells "
+        + "3.29,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testCellTimeInQueueDouble()
+      throws DescriptorParseException {
+    CellStatsBuilder.createWithCellTimeInQueueLine("cell-time-in-queue "
+        + "524.0,1.0,1.0,0.0,0.0,25.0,0.0,0.0,0.0,0.0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testCellCircuitsPerDecileNegative()
+      throws DescriptorParseException {
+    CellStatsBuilder.createWithCellCircuitsPerDecileLine(
+        "cell-circuits-per-decile -866");
+  }
+
+  @Test()
+  public void testConnBiDirectValid()
+      throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithConnBiDirectLine("conn-bi-direct 2012-02-11 01:59:39 "
+        + "(86400 s) 42173,1591,1310,1744");
+    assertEquals(1328925579000L,
+        descriptor.getConnBiDirectStatsEndMillis());
+    assertEquals(86400L, descriptor.getConnBiDirectStatsIntervalLength());
+    assertEquals(42173, descriptor.getConnBiDirectBelow());
+    assertEquals(1591, descriptor.getConnBiDirectRead());
+    assertEquals(1310, descriptor.getConnBiDirectWrite());
+    assertEquals(1744, descriptor.getConnBiDirectBoth());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConnBiDirectStatsFive()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithConnBiDirectLine("conn-bi-direct "
+        + "2012-02-11 01:59:39 (86400 s) 42173,1591,1310,1744,42");
+  }
+
+  @Test()
+  public void testExitStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = ExitStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1328925579000L, descriptor.getExitStatsEndMillis());
+    assertEquals(86400L, descriptor.getExitStatsIntervalLength());
+    String[] ports = new String[] { "25", "80", "443", "49755",
+        "52563", "52596", "57528", "60912", "61351", "64811", "other" };
+    int[] writtenValues = new int[] { 74647, 31370, 20577, 23, 12, 1111,
+        4, 11, 6, 3365, 2592 };
+    int i = 0;
+    for (Map.Entry<String, Long> e :
+        descriptor.getExitKibibytesWritten().entrySet()) {
+      assertEquals(ports[i], e.getKey());
+      assertEquals(writtenValues[i++], e.getValue().intValue());
+    }
+    int[] readValues = new int[] { 35562, 1254256, 110279, 9396, 1911,
+        648, 1188, 1427, 1824, 14, 3054 };
+    i = 0;
+    for (Map.Entry<String, Long> e :
+        descriptor.getExitKibibytesRead().entrySet()) {
+      assertEquals(ports[i], e.getKey());
+      assertEquals(readValues[i++], e.getValue().intValue());
+    }
+    int[] streamsValues = new int[] { 369748, 64212, 151660, 4, 4, 4, 4,
+        4, 4, 4, 1212 };
+    i = 0;
+    for (Map.Entry<String, Long> e :
+        descriptor.getExitStreamsOpened().entrySet()) {
+      assertEquals(ports[i], e.getKey());
+      assertEquals(streamsValues[i++], e.getValue().intValue());
+    }
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitStatsEndNoSeconds()
+      throws DescriptorParseException {
+    ExitStatsBuilder.createWithExitStatsEndLine("exit-stats-end "
+        + "2012-02-11 01:59 (86400 s)");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitStatsWrittenNegativePort()
+      throws DescriptorParseException {
+    ExitStatsBuilder.createWithExitKibibytesWrittenLine(
+        "exit-kibibytes-written -25=74647");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitStatsWrittenUnknown()
+      throws DescriptorParseException {
+    ExitStatsBuilder.createWithExitKibibytesWrittenLine(
+        "exit-kibibytes-written unknown=74647");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitStatsReadNegativeBytes()
+      throws DescriptorParseException {
+    ExitStatsBuilder.createWithExitKibibytesReadLine(
+        "exit-kibibytes-read 25=-35562");
+  }
+
+  @Test()
+  public void testExitStatsReadTooLarge()
+      throws DescriptorParseException {
+    ExitStatsBuilder.createWithExitKibibytesReadLine(
+        "exit-kibibytes-read other=2282907805");
+  }
+
+  @Test()
+  public void testExitStatsStreamsTooLarge()
+      throws DescriptorParseException {
+    ExitStatsBuilder.createWithExitStreamsOpenedLine(
+        "exit-streams-opened 25=2147483648");
+  }
+
+  @Test()
+  public void testBridgeStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = BridgeStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1328925579000L, descriptor.getBridgeStatsEndMillis());
+    assertEquals(86400L, descriptor.getBridgeStatsIntervalLength());
+    SortedMap<String, Integer> ips = descriptor.getBridgeIps();
+    assertNotNull(ips);
+    assertEquals(24, ips.get("ir").intValue());
+    assertEquals(16, ips.get("sy").intValue());
+    assertFalse(ips.containsKey("no"));
+    SortedMap<String, Integer> ver = descriptor.getBridgeIpVersions();
+    assertNotNull(ver);
+    assertEquals(8, ver.get("v4").intValue());
+    assertEquals(16, ver.get("v6").intValue());
+    assertFalse(ver.containsKey("v8"));
+    SortedMap<String, Integer> trans = descriptor.getBridgeIpTransports();
+    assertNotNull(trans);
+    assertEquals(8, trans.get("<OR>").intValue());
+    assertEquals(792, trans.get("obfs2").intValue());
+    assertEquals(1728, trans.get("obfs3").intValue());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBridgeStatsEndIntervalZero()
+      throws DescriptorParseException {
+    BridgeStatsBuilder.createWithBridgeStatsEndLine("bridge-stats-end "
+        + "2012-02-11 01:59:39 (0 s)");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBridgeIpsDouble()
+      throws DescriptorParseException {
+    BridgeStatsBuilder.createWithBridgeIpsLine("bridge-ips ir=24.5");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBridgeIpsNonAsciiKeyword()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithNonAsciiLineBytes(new byte[] {
+        0x14, (byte) 0xfe, 0x18,                  // non-ascii chars
+        0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x2d, // "bridge-"
+        0x69, 0x70, 0x73 }, false);               // "ips" (no newline)
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBridgeIpVersionsDouble()
+      throws DescriptorParseException {
+    BridgeStatsBuilder.createWithBridgeIpVersionsLine(
+        "bridge-ip-versions v4=24.5");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBridgeIpTransportsDouble()
+      throws DescriptorParseException {
+    BridgeStatsBuilder.createWithBridgeIpTransportsLine(
+        "bridge-ip-transports obfs2=24.5");
+  }
+
+  @Test()
+  public void testBridgeIpTransportsUnderscore()
+      throws DescriptorParseException {
+    BridgeStatsBuilder.createWithBridgeIpTransportsLine(
+        "bridge-ip-transports meek=32,obfs3_websocket=8,websocket=64");
+  }
+
+  @Test()
+  public void testHidservStatsValid() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor = HidservStatsBuilder.
+        createWithDefaultLines();
+    assertEquals(1449152816000L, descriptor.getHidservStatsEndMillis());
+    assertEquals(86400L, descriptor.getHidservStatsIntervalLength());
+    assertEquals(36474281.0, descriptor.getHidservRendRelayedCells(),
+        0.0001);
+    Map<String, Double> params =
+        descriptor.getHidservRendRelayedCellsParameters();
+    assertTrue(params.containsKey("delta_f"));
+    assertEquals(2048.0, params.remove("delta_f"), 0.0001);
+    assertTrue(params.containsKey("epsilon"));
+    assertEquals(0.3, params.remove("epsilon"), 0.0001);
+    assertTrue(params.containsKey("bin_size"));
+    assertEquals(1024.0, params.remove("bin_size"), 0.0001);
+    assertTrue(params.isEmpty());
+    assertEquals(-3.0, descriptor.getHidservDirOnionsSeen(), 0.0001);
+    params = descriptor.getHidservDirOnionsSeenParameters();
+    assertTrue(params.containsKey("delta_f"));
+    assertEquals(8.0, params.remove("delta_f"), 0.0001);
+    assertTrue(params.containsKey("epsilon"));
+    assertEquals(0.3, params.remove("epsilon"), 0.0001);
+    assertTrue(params.containsKey("bin_size"));
+    assertEquals(8.0, params.remove("bin_size"), 0.0001);
+    assertTrue(params.isEmpty());
+  }
+
+  @Test()
+  public void testHidservStatsEndLineMissing()
+      throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor =
+        HidservStatsBuilder.createWithHidservStatsEndLine(null);
+    assertEquals(-1L, descriptor.getHidservStatsEndMillis());
+    assertEquals(-1L, descriptor.getHidservStatsIntervalLength());
+  }
+
+  @Test()
+  public void testHidservRendRelayedCellsNoParams()
+        throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor =
+        HidservStatsBuilder.createWithHidservRendRelayedCellsLine(
+        "hidserv-rend-relayed-cells 36474281");
+    assertEquals(36474281.0, descriptor.getHidservRendRelayedCells(),
+        0.0001);
+    assertTrue(
+        descriptor.getHidservRendRelayedCellsParameters().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testHidservDirOnionsSeenCommaSeparatedParams()
+      throws DescriptorParseException {
+    HidservStatsBuilder.createWithHidservDirOnionsSeenLine(
+        "hidserv-dir-onions-seen -3 delta_f=8,epsilon=0.30,bin_size=8");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testHidservDirOnionsSeenNoDoubleParams()
+      throws DescriptorParseException {
+    HidservStatsBuilder.createWithHidservDirOnionsSeenLine(
+        "hidserv-dir-onions-seen -3 delta_f=A epsilon=B bin_size=C");
+  }
+
+  @Test()
+  public void testRouterSignatureOpt()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterSignatureLines("opt "
+        + "router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "crypto lines are ignored anyway\n"
+        + "-----END SIGNATURE-----");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testRouterSignatureNotLastLine()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterSignatureLines("router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
+        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
+        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
+        + "-----END SIGNATURE-----\npublished 2012-02-11 09:08:36");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    DescriptorBuilder.createWithUnrecognizedLine(unrecognizedLine, true);
+  }
+
+  @Test()
+  public void testUnrecognizedLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ExtraInfoDescriptor descriptor = DescriptorBuilder.
+        createWithUnrecognizedLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
+  }
+
+  private static final String IDENTITY_ED25519_LINES =
+      "identity-ed25519\n"
+      + "-----BEGIN ED25519 CERT-----\n"
+      + "AQQABiX1AVGv5BuzJroQXbOh6vv1nbwc5rh2S13PyRFuLhTiifK4AQAgBACBCMwr"
+      + "\n4qgIlFDIzoC9ieJOtSkwrK+yXJPKlP8ojvgkx8cGKvhokOwA1eYDombzfwHcJ1"
+      + "EV\nbhEn/6g8i7wzO3LoqefIUrSAeEExOAOmm5mNmUIzL8EtnT6JHCr/sqUTUgA="
+      + "\n"
+      + "-----END ED25519 CERT-----";
+
+  private static final String MASTER_KEY_ED25519_LINE =
+      "master-key-ed25519 gQjMK+KoCJRQyM6AvYniTrUpMKyvslyTypT/KI74JMc";
+
+  private static final String ROUTER_SIG_ED25519_LINE =
+      "router-sig-ed25519 y7WF9T2GFwkSDPZEhB55HgquIFOl5uXUFMYJPq3CXXUTKeJ"
+      + "kSrtaZUB5s34fWdHQNtl84mH4dVaFMunHnwgYAw";
+
+  @Test()
+  public void testEd25519() throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor =
+        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
+    assertEquals(IDENTITY_ED25519_LINES.substring(
+        IDENTITY_ED25519_LINES.indexOf("\n") + 1),
+        descriptor.getIdentityEd25519());
+    assertEquals(MASTER_KEY_ED25519_LINE.substring(
+        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
+        descriptor.getMasterKeyEd25519());
+    assertEquals(ROUTER_SIG_ED25519_LINE.substring(
+        ROUTER_SIG_ED25519_LINE.indexOf(" ") + 1),
+        descriptor.getRouterSignatureEd25519());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519IdentityMasterKeyMismatch()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        "master-key-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+        ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testEd25519IdentityMissing()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(null,
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519IdentityDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES + "\n"
+        + IDENTITY_ED25519_LINES, MASTER_KEY_ED25519_LINE,
+        ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519IdentityEmptyCrypto()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines("identity-ed25519\n"
+        + "-----BEGIN ED25519 CERT-----\n-----END ED25519 CERT-----",
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testEd25519MasterKeyMissing()
+      throws DescriptorParseException {
+    ExtraInfoDescriptor descriptor =
+        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        null, ROUTER_SIG_ED25519_LINE);
+    assertEquals(MASTER_KEY_ED25519_LINE.substring(
+        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
+        descriptor.getMasterKeyEd25519());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519MasterKeyDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE + "\n" + MASTER_KEY_ED25519_LINE,
+        ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testEd25519RouterSigMissing()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE, null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519RouterSigDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE + "\n"
+        + ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testExtraInfoDigestSha256Relay()
+      throws DescriptorParseException {
+    byte[] descriptorBytes = ("extra-info Unnamed "
+        + "EA5B335055D2F03013FF030381F02B1C631EC723\n"
+        + "identity-ed25519\n"
+        + "-----BEGIN ED25519 CERT-----\n"
+        + "AQQABiZRAenzZorGtx6xapoEeaqcLLOk3uWwJXTvOVLluSXXbRSZAQAgBADLN5"
+        + "wp\nCEOrRbshSbj1NDAUgc6cxU65M/Vx1x+b5+EXbkQZ5uiyB4pphVF5kPPT1P"
+        + "SleYqM\n8j+tlKh2i6+Xr0xScSPpmtG00/D0MoRlT7ZdaaaT5iw1DWDQCZ8BHG"
+        + "lAZwU=\n"
+        + "-----END ED25519 CERT-----\n"
+        + "published 2015-12-01 04:38:12\n"
+        + "write-history 2015-12-01 01:40:37 (14400 s) 88704000,60825600,"
+        + "61747200,76953600,61516800,59443200\n"
+        + "read-history 2015-12-01 01:40:37 (14400 s) 87321600,59443200,"
+        + "59904000,74880000,60364800,58060800\n"
+        + "router-sig-ed25519 c6eUeJs/SVjun3JhmjByEeWdRDyunSMAnGVhx71JiRj"
+        + "YzR8x5IcPebylG7m10wiolFxinvw78UhrrGo9Sq5ZBw\n"
+        + "router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "oC2qFHCDOKSRoIPR86jdRxEYia390Z4d8fT0yr/1mg4RQ7lHmxlzFT6QxCswdX"
+        + "Ry\nvGNGR0wARySgyE+YKKWYn/Hp547JhhWd9Oc7BuFMY0XMvl/HOo+B9VjW+l"
+        + "nv6UBE\niqxx3C3Iw0ymohvOenyCUa/7TmsT7eVotDO57uIoGEc=\n"
+        + "-----END SIGNATURE-----\n"
+        + "").getBytes();
+    RelayExtraInfoDescriptor descriptor =
+        new RelayExtraInfoDescriptorImpl(descriptorBytes, true);
+    assertEquals("Pt1BtzfRwhYqGCDo8jjchS8nJP3ovrDyHGn+dqPbMgw",
+        descriptor.getExtraInfoDigestSha256());
+  }
+
+  @Test()
+  public void testExtraInfoDigestSha256Bridge()
+      throws DescriptorParseException {
+    byte[] descriptorBytes = ("extra-info idideditheconfig "
+        + "DC28749EC9E26E61DE492E46CD830379E9931B09\n"
+        + "master-key-ed25519 "
+        + "38FzmOIE6Mm85Ytx0MhFM6X9EuxWRUgb6HjyMGuO2AU\n"
+        + "published 2015-12-03 13:23:19\n"
+        + "write-history 2015-12-03 09:59:32 (14400 s) 53913600,52992000,"
+        + "53222400,53222400,53452800,53222400\n"
+        + "read-history 2015-12-03 09:59:32 (14400 s) 61056000,60364800,"
+        + "60364800,60134400,60595200,60364800\n"
+        + "geoip-db-digest 5BF366AD4A0572D82A1A0F6628AF8EF7725E3AB9\n"
+        + "geoip6-db-digest 212DE17D5A368DCAFA19B95F168BFFA101145A93\n"
+        + "router-digest-sha256 "
+        + "TvrqpjI7OmCtwGwair/NHUxg5ROVVQYz6/EDyXsDHR4\n"
+        + "router-digest 00B98F076B586272C3172B7F3DA29ADEE75F2ED8\n").getBytes();
+    BridgeExtraInfoDescriptor descriptor =
+        new BridgeExtraInfoDescriptorImpl(descriptorBytes, true);
+    assertEquals("TvrqpjI7OmCtwGwair/NHUxg5ROVVQYz6/EDyXsDHR4",
+        descriptor.getExtraInfoDigestSha256());
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/MicrodescriptorImplTest.java b/src/test/java/org/torproject/descriptor/impl/MicrodescriptorImplTest.java
new file mode 100644
index 0000000..abb51db
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/MicrodescriptorImplTest.java
@@ -0,0 +1,82 @@
+package org.torproject.descriptor.impl;
+
+import org.junit.Test;
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.Microdescriptor;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class MicrodescriptorImplTest {
+
+  /* Helper class to build a microdescriptor based on default data and
+   * modifications requested by test methods. */
+  private static class DescriptorBuilder {
+    private String onionKeyLines = "onion-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIGJAoGBALNZ4pNsHHkl7a+kFWbBmPHNAepjjvuhjTr1TaMB3UKuCRaXJmS2Qr"
+        + "CW\nkTmINqdQUccwb3ghb7EBZfDtCUvjcwMSEsRRTVIZqVQsYj6m3n1CegOc4o"
+        + "UutXaZ\nfkyty5XOgV4Qucx9wokzTMCHlO0V0x9y0FwFsK5Nb6ugqfQLLQ6XAg"
+        + "MBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----";
+    private static Microdescriptor createWithDefaultLines()
+        throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      return new MicrodescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String ntorOnionKeyLine =
+        "ntor-onion-key PXLa7IGE+TzPDMsM5j9rFnDa37rd6kfZa5QuzqqJukw=";
+    private String idLine = "id rsa1024 bvegfGxp8k7T9QFpjPTrPaJTa/8";
+    private static Microdescriptor createWithIdLine(String line)
+        throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.idLine = line;
+      return new MicrodescriptorImpl(db.buildDescriptor(), true);
+    }
+    private byte[] buildDescriptor() {
+      StringBuilder sb = new StringBuilder();
+      if (this.onionKeyLines != null) {
+        sb.append(this.onionKeyLines).append("\n");
+      }
+      if (this.ntorOnionKeyLine != null) {
+        sb.append(this.ntorOnionKeyLine).append("\n");
+      }
+      if (this.idLine != null) {
+        sb.append(this.idLine).append("\n");
+      }
+      return sb.toString().getBytes();
+    }
+  }
+
+  @Test()
+  public void testDefaults() throws DescriptorParseException {
+    DescriptorBuilder.createWithDefaultLines();
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIdRsa1024TooShort() throws DescriptorParseException {
+    DescriptorBuilder.createWithIdLine("id rsa1024 AAAA");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIdRsa1024TooLong() throws DescriptorParseException {
+    DescriptorBuilder.createWithIdLine("id ed25519 AAAAAAAAAAAAAAAAAAAAAA"
+        + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIdRsa512() throws DescriptorParseException {
+    DescriptorBuilder.createWithIdLine("id rsa512 "
+        + "bvegfGxp8k7T9QFpjPTrPaJTa/8");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIdEd25519Duplicate() throws DescriptorParseException {
+    DescriptorBuilder.createWithIdLine(
+        "id rsa1024 bvegfGxp8k7T9QFpjPTrPaJTa/8\n"
+        + "id rsa1024 bvegfGxp8k7T9QFpjPTrPaJTa/8");
+  }
+}
diff --git a/src/test/java/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImplTest.java b/src/test/java/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImplTest.java
new file mode 100644
index 0000000..d864337
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImplTest.java
@@ -0,0 +1,1272 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.DirectorySignature;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+
+import org.junit.Test;
+import org.torproject.descriptor.NetworkStatusEntry;
+import org.torproject.descriptor.RelayNetworkStatusConsensus;
+
+/* TODO Add test cases for all lines starting with "opt ". */
+
+/* Test parsing of network status consensuses.  The main focus is on
+ * making sure that the parser is as robust as possible and doesn't break,
+ * no matter what gets fed into it.  A secondary focus is to ensure that
+ * a parsed consensus is fully compatible to dir-spec.txt. */
+public class RelayNetworkStatusConsensusImplTest {
+
+  /* Helper class to build a directory source based on default data and
+   * modifications requested by test methods. */
+  private static class DirSourceBuilder {
+    private static RelayNetworkStatusConsensus
+        createWithDirSource(String dirSourceString)
+        throws DescriptorParseException {
+      ConsensusBuilder cb = new ConsensusBuilder();
+      cb.dirSources.add(dirSourceString);
+      return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+          true);
+    }
+    private String nickname = "gabelmoo";
+    private static RelayNetworkStatusConsensus
+        createWithNickname(String string)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.nickname = string;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String identity = "ED03BB616EB2F60BEC80151114BB25CEF515B226";
+    private static RelayNetworkStatusConsensus
+        createWithIdentity(String string)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.identity = string;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String hostName = "212.112.245.170";
+    private static RelayNetworkStatusConsensus
+        createWithHostName(String string)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.hostName = string;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String address = "212.112.245.170";
+    private static RelayNetworkStatusConsensus
+        createWithAddress(String string)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.address = string;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String dirPort = "80";
+    private static RelayNetworkStatusConsensus
+        createWithDirPort(String string)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.dirPort = string;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String orPort = "443";
+    private static RelayNetworkStatusConsensus
+        createWithOrPort(String string)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.orPort = string;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String contactLine = "contact 4096R/C5AA446D Sebastian Hahn "
+        + "<tor@xxxxxxxxxxxxxxxxx>";
+    private static RelayNetworkStatusConsensus
+        createWithContactLine(String line)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.contactLine = line;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String voteDigestLine =
+        "vote-digest 0F398A5834D2C139E1D92310B09F814F243354D1";
+    private static RelayNetworkStatusConsensus
+        createWithVoteDigestLine(String line)
+        throws DescriptorParseException {
+      DirSourceBuilder dsb = new DirSourceBuilder();
+      dsb.voteDigestLine = line;
+      return createWithDirSource(dsb.buildDirSource());
+    }
+    private String buildDirSource() {
+      StringBuilder sb = new StringBuilder();
+      String dirSourceLine = "dir-source " + this.nickname + " "
+          + this.identity + " " + this.hostName + " " + this.address + " "
+          + this.dirPort + " " + this.orPort;
+      sb.append(dirSourceLine).append("\n");
+      if (this.contactLine != null) {
+        sb.append(this.contactLine).append("\n");
+      }
+      if (this.voteDigestLine != null) {
+        sb.append(this.voteDigestLine).append("\n");
+      }
+      String dirSourceWithTrailingNewLine = sb.toString();
+      String dirSource = dirSourceWithTrailingNewLine.substring(0,
+          dirSourceWithTrailingNewLine.length() - 1);
+      return dirSource;
+    }
+  }
+
+  /* Helper class to build a status entry based on default data and
+   * modifications requested by test methods. */
+  private static class StatusEntryBuilder {
+    private static RelayNetworkStatusConsensus
+        createWithStatusEntry(String statusEntryString)
+        throws DescriptorParseException {
+      ConsensusBuilder cb = new ConsensusBuilder();
+      cb.statusEntries.add(statusEntryString);
+      return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+          true);
+    }
+    private String nickname = "right2privassy3";
+    private static RelayNetworkStatusConsensus
+        createWithNickname(String string)
+        throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.nickname = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String fingerprintBase64 = "ADQ6gCT3DiFHKPDFr3rODBUI8HM";
+    private static RelayNetworkStatusConsensus
+        createWithFingerprintBase64(String string)
+        throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.fingerprintBase64 = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String descriptorBase64 = "Yiti+nayuT2Efe2X1+M4nslwVuU";
+    private static RelayNetworkStatusConsensus
+        createWithDescriptorBase64(String string)
+        throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.descriptorBase64 = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String publishedString = "2011-11-29 21:34:27";
+    private static RelayNetworkStatusConsensus
+        createWithPublishedString(String string)
+        throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.publishedString = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String address = "50.63.8.215";
+    private static RelayNetworkStatusConsensus
+        createWithAddress(String string) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.address = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String orPort = "9023";
+    private static RelayNetworkStatusConsensus
+        createWithOrPort(String string) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.orPort = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String dirPort = "0";
+    private static RelayNetworkStatusConsensus
+        createWithDirPort(String string) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.dirPort = string;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String sLine = "s Exit Fast Named Running Stable Valid";
+    private static RelayNetworkStatusConsensus
+        createWithSLine(String line) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.sLine = line;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String vLine = "v Tor 0.2.1.29 (r8e9b25e6c7a2e70c)";
+    private static RelayNetworkStatusConsensus
+        createWithVLine(String line) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.vLine = line;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String wLine = "w Bandwidth=1";
+    private static RelayNetworkStatusConsensus
+        createWithWLine(String line) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.wLine = line;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String pLine = "p accept 80,1194,1220,1293";
+    private static RelayNetworkStatusConsensus
+        createWithPLine(String line) throws DescriptorParseException {
+      StatusEntryBuilder seb = new StatusEntryBuilder();
+      seb.pLine = line;
+      return createWithStatusEntry(seb.buildStatusEntry());
+    }
+    private String buildStatusEntry() {
+      StringBuilder sb = new StringBuilder();
+      String rLine = "r " + nickname + " " + fingerprintBase64 + " "
+          + descriptorBase64 + " " + publishedString + " " + address + " "
+          + orPort + " " + dirPort;
+      sb.append(rLine).append("\n");
+      if (this.sLine != null) {
+        sb.append(this.sLine).append("\n");
+      }
+      if (this.vLine != null) {
+        sb.append(this.vLine).append("\n");
+      }
+      if (this.wLine != null) {
+        sb.append(this.wLine).append("\n");
+      }
+      if (this.pLine != null) {
+        sb.append(this.pLine).append("\n");
+      }
+      String statusEntryWithTrailingNewLine = sb.toString();
+      String statusEntry = statusEntryWithTrailingNewLine.substring(0,
+          statusEntryWithTrailingNewLine.length() - 1);
+      return statusEntry;
+    }
+  }
+
+  /* Helper class to build a directory signature based on default data and
+   * modifications requested by test methods. */
+  private static class DirectorySignatureBuilder {
+    private static RelayNetworkStatusConsensus
+        createWithDirectorySignature(String directorySignatureString)
+        throws DescriptorParseException {
+      ConsensusBuilder cb = new ConsensusBuilder();
+      cb.addDirectorySignature(directorySignatureString);
+      return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
+          true);
+    }
+    private String identity = "ED03BB616EB2F60BEC80151114BB25CEF515B226";
+    private static RelayNetworkStatusConsensus
+        createWithIdentity(String string)
+        throws DescriptorParseException {
+      DirectorySignatureBuilder dsb = new DirectorySignatureBuilder();
+      dsb.identity = string;
+      return createWithDirectorySignature(dsb.buildDirectorySignature());
+    }
+    private String signingKey =
+        "845CF1D0B370CA443A8579D18E7987E7E532F639";
+    private static RelayNetworkStatusConsensus
+        createWithSigningKey(String string)
+        throws DescriptorParseException {
+      DirectorySignatureBuilder dsb = new DirectorySignatureBuilder();
+      dsb.signingKey = string;
+      return createWithDirectorySignature(dsb.buildDirectorySignature());
+    }
+    private String buildDirectorySignature() {
+      String directorySignature = "directory-signature " + identity + " "
+          + signingKey + "\n"
+          + "-----BEGIN SIGNATURE-----\n"
+          + "gE64+/4BH43v1+7jS9FK1tu2+94at8xhVSPn4O/PpOx7b0Yb+S1hac1QHAiS"
+                + "Ll+k\n"
+          + "6OiANKzhj54WHSrUswBPrOzjmKj0OhGXSAe5nHZUFX9a1MDQLDCoZBj536X9"
+                + "P3JG\n"
+          + "z89A+wrsN17I5490y66AEvws54BYZMbgRfp8HXn/0Ss=\n"
+          + "-----END SIGNATURE-----";
+      return directorySignature;
+    }
+  }
+
+  @Test()
+  public void testSampleConsensus() throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    RelayNetworkStatusConsensus consensus =
+        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+    assertEquals(3, consensus.getNetworkStatusVersion());
+    assertEquals(11, consensus.getConsensusMethod());
+    assertEquals(1322643600000L, consensus.getValidAfterMillis());
+    assertEquals(1322647200000L, consensus.getFreshUntilMillis());
+    assertEquals(1322654400000L, consensus.getValidUntilMillis());
+    assertEquals(300L, consensus.getVoteSeconds());
+    assertEquals(300L, consensus.getDistSeconds());
+    assertTrue(consensus.getRecommendedClientVersions().contains(
+        "0.2.3.8-alpha"));
+    assertTrue(consensus.getRecommendedServerVersions().contains(
+        "0.2.3.8-alpha"));
+    assertTrue(consensus.getKnownFlags().contains("Running"));
+    assertEquals(30000, (int) consensus.getConsensusParams().get(
+        "CircuitPriorityHalflifeMsec"));
+    assertEquals("86.59.21.38", consensus.getDirSourceEntries().get(
+        "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4").getHostname());
+    assertEquals("86.59.21.38", consensus.getDirSourceEntries().get(
+        "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4").getIp());
+    assertTrue(consensus.containsStatusEntry(
+        "00795A6E8D91C270FC23B30F388A495553E01894"));
+    assertEquals("188.177.149.216", consensus.getStatusEntry(
+        "00795A6E8D91C270FC23B30F388A495553E01894").getAddress());
+    for (DirectorySignature signature : consensus.getSignatures()) {
+      if ("14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4".equals(
+          signature.getIdentity())) {
+        assertEquals("3509BA5A624403A905C74DA5C8A0CEC9E0D3AF86",
+            signature.getSigningKeyDigest());
+      }
+    }
+    assertEquals(285, (int) consensus.getBandwidthWeights().get("Wbd"));
+    assertTrue(consensus.getUnrecognizedLines().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNoLine()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNewLine()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version 3\n");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNewLineSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version 3\n ");
+  }
+
+  @Test()
+  public void testNetworkStatusVersionPrefixLineAtChar()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "@consensus\nnetwork-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionPrefixLine()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "directory-footer\nnetwork-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionPrefixLinePoundChar()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "#consensus\nnetwork-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNoSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionOneSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersion42()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version 42");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionFourtyTwo()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version FourtyTwo");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusNoLine() throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionSpaceBefore()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithNetworkStatusVersionLine(
+        " network-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusSpaceBefore() throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine(" vote-status consensus");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusNoSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine("vote-status");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusOneSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine("vote-status ");
+  }
+
+  @Test()
+  public void testVoteStatusConsensusOneSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine("vote-status consensus ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusVote() throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine("vote-status vote");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusTheMagicVoteStatus()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithVoteStatusLine(
+        "vote-status TheMagicVoteStatus");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodNoLine()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodNoSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine("consensus-method");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodOneSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine("consensus-method ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodEleven()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine(
+        "consensus-method eleven");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodMinusOne()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine("consensus-method -1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodNinePeriod()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine("consensus-method "
+        + "999999999999999999999999999999999999999999999999999999999999");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodTwoLines()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithConsensusMethodLine(
+        "consensus-method 1\nconsensus-method 1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterNoLine() throws DescriptorParseException {
+    ConsensusBuilder.createWithValidAfterLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterNoSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithValidAfterLine("valid-after");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterOneSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithValidAfterLine("valid-after ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterLongAgo() throws DescriptorParseException {
+    ConsensusBuilder.createWithValidAfterLine("valid-after long ago");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterFeb30() throws DescriptorParseException {
+    ConsensusBuilder.createWithValidAfterLine(
+        "valid-after 2011-02-30 09:00:00");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFreshUntilNoLine() throws DescriptorParseException {
+    ConsensusBuilder.createWithFreshUntilLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFreshUntilAroundTen() throws DescriptorParseException {
+    ConsensusBuilder.createWithFreshUntilLine(
+        "fresh-until 2011-11-30 around ten");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidUntilTomorrowMorning()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithValidUntilLine(
+        "valid-until tomorrow morning");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayNoLine() throws DescriptorParseException {
+    ConsensusBuilder.createWithVotingDelayLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayNoSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithVotingDelayLine("voting-delay");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayOneSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithVotingDelayLine("voting-delay ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayTriple() throws DescriptorParseException {
+    ConsensusBuilder.createWithVotingDelayLine(
+        "voting-delay 300 300 300");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelaySingle() throws DescriptorParseException {
+    ConsensusBuilder.createWithVotingDelayLine("voting-delay 300");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayOneTwo() throws DescriptorParseException {
+    ConsensusBuilder.createWithVotingDelayLine("voting-delay one two");
+  }
+
+  @Test()
+  public void testClientServerVersionsNoLine()
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.clientVersionsLine = null;
+    cb.serverVersionsLine = null;
+    RelayNetworkStatusConsensus consensus =
+        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+    assertNull(consensus.getRecommendedClientVersions());
+    assertNull(consensus.getRecommendedServerVersions());
+  }
+
+  @Test()
+  public void testServerVersionsNoLine() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithServerVersionsLine(null);
+    assertNotNull(consensus.getRecommendedClientVersions());
+    assertNull(consensus.getRecommendedServerVersions());
+  }
+
+  @Test()
+  public void testClientVersionsNoLine() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithClientVersionsLine(null);
+    assertNull(consensus.getRecommendedClientVersions());
+    assertNotNull(consensus.getRecommendedServerVersions());
+  }
+
+  @Test()
+  public void testClientVersionsNoSpace()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithClientVersionsLine("client-versions");
+    assertNotNull(consensus.getRecommendedClientVersions());
+    assertTrue(consensus.getRecommendedClientVersions().isEmpty());
+  }
+
+  @Test()
+  public void testClientVersionsOneSpace()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithClientVersionsLine("client-versions ");
+    assertNotNull(consensus.getRecommendedClientVersions());
+    assertTrue(consensus.getRecommendedClientVersions().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testClientVersionsComma() throws DescriptorParseException {
+    ConsensusBuilder.createWithClientVersionsLine("client-versions ,");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testClientVersionsCommaVersion()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithClientVersionsLine(
+        "client-versions ,0.2.2.34");
+  }
+
+  @Test()
+  public void testPackageNone() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithPackageLines(null);
+    assertNull(consensus.getPackageLines());
+  }
+
+  @Test()
+  public void testPackageOne() throws DescriptorParseException {
+    String packageLine = "package shouldbesecond 0 http digest=digest";
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithPackageLines(packageLine);
+    assertEquals(packageLine.substring("package ".length()),
+        consensus.getPackageLines().get(0));
+  }
+
+  @Test()
+  public void testPackageTwo() throws DescriptorParseException {
+    List<String> packageLines = Arrays.asList(
+        "package shouldbesecond 0 http digest=digest",
+        "package outoforder 0 http digest=digest");
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithPackageLines(packageLines.get(0)
+        + "\n" + packageLines.get(1));
+    for (int i = 0; i < packageLines.size(); i++) {
+      assertEquals(packageLines.get(i).substring("package ".length()),
+          consensus.getPackageLines().get(i));
+    }
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPackageIncomplete() throws DescriptorParseException {
+    String packageLine = "package shouldbesecond 0 http";
+    ConsensusBuilder.createWithPackageLines(packageLine);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testKnownFlagsNoLine() throws DescriptorParseException {
+    ConsensusBuilder.createWithKnownFlagsLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testKnownFlagsNoSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithKnownFlagsLine("known-flags");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testKnownFlagsOneSpace() throws DescriptorParseException {
+    ConsensusBuilder.createWithKnownFlagsLine("known-flags ");
+  }
+
+  @Test()
+  public void testParamsNoLine() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithParamsLine(null);
+    assertNull(consensus.getConsensusParams());
+  }
+
+  @Test()
+  public void testParamsNoSpace() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithParamsLine("params");
+    assertNotNull(consensus.getConsensusParams());
+    assertTrue(consensus.getConsensusParams().isEmpty());
+  }
+
+  @Test()
+  public void testParamsOneSpace() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithParamsLine("params ");
+    assertNotNull(consensus.getConsensusParams());
+    assertTrue(consensus.getConsensusParams().isEmpty());
+  }
+
+  @Test()
+  public void testParamsThreeSpaces() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithParamsLine("params   ");
+    assertNotNull(consensus.getConsensusParams());
+    assertTrue(consensus.getConsensusParams().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testParamsNoEqualSign() throws DescriptorParseException {
+    ConsensusBuilder.createWithParamsLine("params key-value");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testParamsOneTooLargeNegative()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithParamsLine("params min=-2147483649");
+  }
+
+  @Test()
+  public void testParamsLargestNegative()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithParamsLine("params min=-2147483648");
+    assertEquals(1, consensus.getConsensusParams().size());
+    assertEquals(-2147483648,
+        (int) consensus.getConsensusParams().get("min"));
+  }
+
+  @Test()
+  public void testParamsLargestPositive()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithParamsLine("params max=2147483647");
+    assertEquals(1, consensus.getConsensusParams().size());
+    assertEquals(2147483647,
+        (int) consensus.getConsensusParams().get("max"));
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testParamsOneTooLargePositive()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithParamsLine("params max=2147483648");
+  }
+
+  @Test()
+  public void testDirSourceLegacyNickname()
+      throws DescriptorParseException {
+    DirSourceBuilder dsb = new DirSourceBuilder();
+    dsb.nickname = "gabelmoo-legacy";
+    dsb.identity = "81349FC1F2DBA2C2C11B45CB9706637D480AB913";
+    dsb.contactLine = null;
+    dsb.voteDigestLine = null;
+    RelayNetworkStatusConsensus consensus =
+        DirSourceBuilder.createWithDirSource(dsb.buildDirSource());
+    assertEquals(3, consensus.getDirSourceEntries().size());
+    assertTrue(consensus.getDirSourceEntries().get(
+        "81349FC1F2DBA2C2C11B45CB9706637D480AB913").isLegacy());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceNicknameTooLong()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithNickname("gabelmooisfinebutthisistoolong");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceIdentityTooShort()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithIdentity("ED03BB616EB2F60BEC8015111");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceIdentityTooLong()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithIdentity("ED03BB616EB2F60BEC8015111"
+        + "4BB25CEF515B226ED03BB616EB2F60BEC8");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceHostnameMissing()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithHostName("");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceAddress24() throws DescriptorParseException {
+    DirSourceBuilder.createWithAddress("212.112.245");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceAddress40() throws DescriptorParseException {
+    DirSourceBuilder.createWithAddress("212.112.245.170.123");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceDirPortMinusOne()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithDirPort("-1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceDirPort66666()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithDirPort("66666");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceDirPortOnions()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithDirPort("onions");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceOrPortOnions()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithOrPort("onions");
+  }
+
+  @Test()
+  public void testDirSourceContactNoLine()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        DirSourceBuilder.createWithContactLine(null);
+    assertNull(consensus.getDirSourceEntries().get(
+        "ED03BB616EB2F60BEC80151114BB25CEF515B226").getContactLine());
+  }
+
+  @Test()
+  public void testDirSourceContactLineNoSpace()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        DirSourceBuilder.createWithContactLine("contact");
+    assertNotNull(consensus.getDirSourceEntries().get(
+        "ED03BB616EB2F60BEC80151114BB25CEF515B226").getContactLine());
+  }
+
+  @Test()
+  public void testDirSourceContactLineOneSpace()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        DirSourceBuilder.createWithContactLine("contact ");
+    assertNotNull(consensus.getDirSourceEntries().get(
+        "ED03BB616EB2F60BEC80151114BB25CEF515B226").getContactLine());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceVoteDigestNoLine()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithVoteDigestLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceVoteDigestLineNoSpace()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithVoteDigestLine("vote-digest");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceVoteDigestLineOneSpace()
+      throws DescriptorParseException {
+    DirSourceBuilder.createWithVoteDigestLine("vote-digest ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameNotAllowedChars()
+      throws DescriptorParseException {
+    StatusEntryBuilder.createWithNickname("notAll()wed");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameTooLong() throws DescriptorParseException {
+    StatusEntryBuilder.createWithNickname("1234567890123456789tooLong");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooShort() throws DescriptorParseException {
+    StatusEntryBuilder.createWithFingerprintBase64("TooShort");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintEndsWithEqualSign()
+      throws DescriptorParseException {
+    StatusEntryBuilder.createWithFingerprintBase64(
+        "ADQ6gCT3DiFHKPDFr3rODBUI8H=");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooLong() throws DescriptorParseException {
+    StatusEntryBuilder.createWithFingerprintBase64(
+        "ADQ6gCT3DiFHKPDFr3rODBUI8HMAAAA");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDescriptorTooShort() throws DescriptorParseException {
+    StatusEntryBuilder.createWithDescriptorBase64("TooShort");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDescriptorEndsWithEqualSign()
+      throws DescriptorParseException {
+    StatusEntryBuilder.createWithDescriptorBase64(
+        "ADQ6gCT3DiFHKPDFr3rODBUI8H=");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDescriptorTooLong() throws DescriptorParseException {
+    StatusEntryBuilder.createWithDescriptorBase64(
+        "Yiti+nayuT2Efe2X1+M4nslwVuUAAAA");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublished1960() throws DescriptorParseException {
+    StatusEntryBuilder.createWithPublishedString("1960-11-29 21:34:27");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublished9999() throws DescriptorParseException {
+    StatusEntryBuilder.createWithPublishedString("9999-11-29 21:34:27");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddress256() throws DescriptorParseException {
+    StatusEntryBuilder.createWithAddress("256.63.8.215");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddress24() throws DescriptorParseException {
+    StatusEntryBuilder.createWithAddress("50.63.8/24");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddressV6() throws DescriptorParseException {
+    StatusEntryBuilder.createWithAddress("::1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPort66666() throws DescriptorParseException {
+    StatusEntryBuilder.createWithOrPort("66666");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPortEighty() throws DescriptorParseException {
+    StatusEntryBuilder.createWithOrPort("eighty");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirPortMinusOne() throws DescriptorParseException {
+    StatusEntryBuilder.createWithDirPort("-1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirPortZero() throws DescriptorParseException {
+    StatusEntryBuilder.createWithDirPort("zero");
+  }
+
+  @Test()
+  public void testSLineNoSpace() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        StatusEntryBuilder.createWithSLine("s");
+    assertTrue(consensus.getStatusEntry(
+        "00343A8024F70E214728F0C5AF7ACE0C1508F073").getFlags().isEmpty());
+  }
+
+  @Test()
+  public void testSLineOneSpace() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        StatusEntryBuilder.createWithSLine("s ");
+    assertTrue(consensus.getStatusEntry(
+        "00343A8024F70E214728F0C5AF7ACE0C1508F073").getFlags().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTwoSLines() throws DescriptorParseException {
+    StatusEntryBuilder sb = new StatusEntryBuilder();
+    sb.sLine = sb.sLine + "\n" + sb.sLine;
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.statusEntries.add(sb.buildStatusEntry());
+    new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWLineNoSpace() throws DescriptorParseException {
+    StatusEntryBuilder.createWithWLine("w");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWLineOneSpace() throws DescriptorParseException {
+    StatusEntryBuilder.createWithWLine("w ");
+  }
+
+  @Test()
+  public void testWLineWarpSeven() throws DescriptorParseException {
+    StatusEntryBuilder.createWithWLine("w Warp=7");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTwoWLines() throws DescriptorParseException {
+    StatusEntryBuilder sb = new StatusEntryBuilder();
+    sb.wLine = sb.wLine + "\n" + sb.wLine;
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.statusEntries.add(sb.buildStatusEntry());
+    new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+
+  @Test()
+  public void testWLineUnmeasured() throws DescriptorParseException {
+    StatusEntryBuilder sb = new StatusEntryBuilder();
+    sb.wLine = "w Bandwidth=42424242 Unmeasured=1";
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.statusEntries.add(sb.buildStatusEntry());
+    RelayNetworkStatusConsensus consensus =
+        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+    for (NetworkStatusEntry s : consensus.getStatusEntries().values()) {
+      if (s.getBandwidth() == 42424242L) {
+        assertTrue(s.getUnmeasured());
+      }
+    }
+  }
+
+  @Test()
+  public void testWLineNotUnmeasured() throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        StatusEntryBuilder.createWithWLine("w Bandwidth=20");
+    for (NetworkStatusEntry s : consensus.getStatusEntries().values()) {
+      assertFalse(s.getUnmeasured());
+    }
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPLineNoPolicy() throws DescriptorParseException {
+    StatusEntryBuilder.createWithPLine("p 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPLineNoPorts() throws DescriptorParseException {
+    StatusEntryBuilder.createWithPLine("p accept");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPLineNoPolicyNoPorts() throws DescriptorParseException {
+    StatusEntryBuilder.createWithPLine("p ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPLineProject() throws DescriptorParseException {
+    StatusEntryBuilder.createWithPLine("p project 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTwoPLines() throws DescriptorParseException {
+    StatusEntryBuilder sb = new StatusEntryBuilder();
+    sb.pLine = sb.pLine + "\n" + sb.pLine;
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.statusEntries.add(sb.buildStatusEntry());
+    new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+  }
+
+  @Test()
+  public void testNoStatusEntries() throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.statusEntries.clear();
+    RelayNetworkStatusConsensus consensus =
+        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+    assertFalse(consensus.containsStatusEntry(
+        "00795A6E8D91C270FC23B30F388A495553E01894"));
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirectoryFooterNoLine()
+      throws DescriptorParseException {
+    /* This breaks, because a bandwidth-weights line without a preceding
+     * directory-footer line is not allowed. */
+    ConsensusBuilder.createWithDirectoryFooterLine(null);
+  }
+
+  @Test()
+  public void testDirectoryFooterMissing()
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.setDirectoryFooterLine(null);
+    cb.setBandwidthWeightsLine(null);
+    /* This does not break, because directory footers were optional before
+     * consensus method 9. */
+    RelayNetworkStatusConsensus consensus =
+        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
+    assertNull(consensus.getBandwidthWeights());
+  }
+
+  @Test()
+  public void testDirectoryFooterLineSpace()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithDirectoryFooterLine("directory-footer ");
+  }
+
+  @Test()
+  public void testBandwidthWeightsNoLine()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus =
+        ConsensusBuilder.createWithBandwidthWeightsLine(null);
+    assertNull(consensus.getBandwidthWeights());
+  }
+
+  @Test()
+  public void testBandwidthWeightsLineNoSpace()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithBandwidthWeightsLine("bandwidth-weights");
+    assertNotNull(consensus.getBandwidthWeights());
+  }
+
+  @Test()
+  public void testBandwidthWeightsLineOneSpace()
+      throws DescriptorParseException {
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithBandwidthWeightsLine("bandwidth-weights ");
+    assertNotNull(consensus.getBandwidthWeights());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBandwidthWeightsLineNoEqualSign()
+      throws DescriptorParseException {
+    ConsensusBuilder.createWithBandwidthWeightsLine(
+        "bandwidth-weights Wbd-285");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirectorySignatureIdentityTooShort()
+      throws DescriptorParseException {
+    DirectorySignatureBuilder.createWithIdentity("ED03BB616EB2F60");
+  }
+
+  @Test()
+  public void testDirectorySignatureIdentityTooLong()
+      throws DescriptorParseException {
+    /* This hex string has an unusual length of 58 hex characters, but
+     * dir-spec.txt only requires a hex string, and we can't know all hex
+     * string lengths for all future digest algorithms, so let's just
+     * accept this. */
+    DirectorySignatureBuilder.createWithIdentity(
+        "ED03BB616EB2F60BEC80151114BB25CEF515B226ED03BB616EB2F60BEC");
+  }
+
+  @Test()
+  public void testDirectorySignatureSigningKeyTooShort()
+      throws DescriptorParseException {
+    /* See above, we accept this hex string even though it's unusually
+     * short. */
+    DirectorySignatureBuilder.createWithSigningKey("845CF1D0B370CA");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirectorySignatureSigningKeyTooShortOddNumber()
+      throws DescriptorParseException {
+    /* We don't accept this hex string, because it contains an odd number
+     * of hex characters. */
+    DirectorySignatureBuilder.createWithSigningKey("845");
+  }
+
+  @Test()
+  public void testDirectorySignatureSigningKeyTooLong()
+      throws DescriptorParseException {
+    /* See above, we accept this hex string even though it's unusually
+     * long. */
+    DirectorySignatureBuilder.createWithSigningKey(
+        "845CF1D0B370CA443A8579D18E7987E7E532F639845CF1D0B370CA443A");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNonAsciiByte20() throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    byte[] consensusBytes = cb.buildConsensus();
+    consensusBytes[20] = (byte) 200;
+    new RelayNetworkStatusConsensusImpl(consensusBytes, true);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNonAsciiByteMinusOne()
+      throws DescriptorParseException {
+    ConsensusBuilder cb = new ConsensusBuilder();
+    cb.networkStatusVersionLine = "Xnetwork-status-version 3";
+    byte[] consensusBytes = cb.buildConsensus();
+    consensusBytes[0] = (byte) 200;
+    new RelayNetworkStatusConsensusImpl(consensusBytes, true);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedHeaderLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ConsensusBuilder.createWithUnrecognizedHeaderLine(unrecognizedLine,
+        true);
+  }
+
+  @Test()
+  public void testUnrecognizedHeaderLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithUnrecognizedHeaderLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedDirSourceLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ConsensusBuilder.createWithUnrecognizedDirSourceLine(unrecognizedLine,
+        true);
+  }
+
+  @Test()
+  public void testUnrecognizedDirSourceLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithUnrecognizedDirSourceLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedStatusEntryLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ConsensusBuilder.createWithUnrecognizedStatusEntryLine(
+        unrecognizedLine, true);
+  }
+
+  @Test()
+  public void testUnrecognizedStatusEntryLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithUnrecognizedStatusEntryLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedDirectoryFooterLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ConsensusBuilder.createWithUnrecognizedFooterLine(unrecognizedLine,
+        true);
+  }
+
+  @Test()
+  public void testUnrecognizedDirectoryFooterLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithUnrecognizedFooterLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedDirectorySignatureLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ConsensusBuilder.createWithUnrecognizedDirectorySignatureLine(
+        unrecognizedLine, true);
+  }
+
+  @Test()
+  public void testUnrecognizedDirectorySignatureLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
+        createWithUnrecognizedDirectorySignatureLine(unrecognizedLine,
+        false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/RelayNetworkStatusVoteImplTest.java b/src/test/java/org/torproject/descriptor/impl/RelayNetworkStatusVoteImplTest.java
new file mode 100644
index 0000000..1c840f5
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/RelayNetworkStatusVoteImplTest.java
@@ -0,0 +1,1373 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.DirectorySignature;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Test;
+import org.torproject.descriptor.RelayNetworkStatusVote;
+
+/* TODO Add test cases for all lines starting with "opt ". */
+
+/* Test parsing of network status votes.  Some of the vote-parsing code is
+ * already tested in the consensus-parsing tests.  The tests in this class
+ * focus on the differences between votes and consensuses that are mostly
+ * in the directory header. */
+public class RelayNetworkStatusVoteImplTest {
+
+  /* Helper class to build a vote based on default data and modifications
+   * requested by test methods. */
+  private static class VoteBuilder {
+    private String networkStatusVersionLine = "network-status-version 3";
+    private static RelayNetworkStatusVote
+        createWithNetworkStatusVersionLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.networkStatusVersionLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String voteStatusLine = "vote-status vote";
+    private static RelayNetworkStatusVote
+        createWithVoteStatusLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.voteStatusLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String consensusMethodsLine =
+        "consensus-methods 1 2 3 4 5 6 7 8 9 10 11";
+    private static RelayNetworkStatusVote
+        createWithConsensusMethodsLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.consensusMethodsLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String publishedLine = "published 2011-11-30 08:50:01";
+    private static RelayNetworkStatusVote
+        createWithPublishedLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.publishedLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String validAfterLine = "valid-after 2011-11-30 09:00:00";
+    private static RelayNetworkStatusVote
+        createWithValidAfterLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.validAfterLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String freshUntilLine = "fresh-until 2011-11-30 10:00:00";
+    private static RelayNetworkStatusVote
+        createWithFreshUntilLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.freshUntilLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String validUntilLine = "valid-until 2011-11-30 12:00:00";
+    private static RelayNetworkStatusVote
+        createWithValidUntilLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.validUntilLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String votingDelayLine = "voting-delay 300 300";
+    private static RelayNetworkStatusVote
+        createWithVotingDelayLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.votingDelayLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String clientVersionsLine = "client-versions 0.2.1.31,"
+        + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
+    private static RelayNetworkStatusVote
+        createWithClientVersionsLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.clientVersionsLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String serverVersionsLine = "server-versions 0.2.1.31,"
+        + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
+    private static RelayNetworkStatusVote
+        createWithServerVersionsLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.serverVersionsLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String packageLines = null;
+    protected static RelayNetworkStatusVote
+        createWithPackageLines(String lines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.packageLines = lines;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String knownFlagsLine = "known-flags Authority BadExit Exit "
+        + "Fast Guard HSDir Named Running Stable Unnamed V2Dir Valid";
+    private static RelayNetworkStatusVote
+        createWithKnownFlagsLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.knownFlagsLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String flagThresholdsLine = "flag-thresholds "
+        + "stable-uptime=693369 stable-mtbf=153249 fast-speed=40960 "
+        + "guard-wfu=94.669% guard-tk=691200 guard-bw-inc-exits=174080 "
+        + "guard-bw-exc-exits=184320 enough-mtbf=1";
+    private static RelayNetworkStatusVote
+        createWithFlagThresholdsLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.flagThresholdsLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String paramsLine = "params "
+        + "CircuitPriorityHalflifeMsec=30000 bwauthbestratio=1 "
+        + "bwauthcircs=1 bwauthdescbw=0 bwauthkp=10000 bwauthpid=1 "
+        + "bwauthtd=5000 bwauthti=50000 bwauthtidecay=5000 cbtnummodes=3 "
+        + "cbtquantile=80 circwindow=1000 refuseunknownexits=1";
+    private static RelayNetworkStatusVote
+        createWithParamsLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.paramsLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirSourceLine = "dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80";
+    private static RelayNetworkStatusVote
+        createWithDirSourceLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirSourceLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String contactLine = "contact 4096R/E012B42D Jacob Appelbaum "
+        + "<jacob@xxxxxxxxxxxxx>";
+    private static RelayNetworkStatusVote
+        createWithContactLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.contactLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String legacyDirKeyLine = null;
+    private static RelayNetworkStatusVote
+        createWithLegacyDirKeyLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.legacyDirKeyLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirKeyCertificateVersionLine =
+        "dir-key-certificate-version 3";
+    private static RelayNetworkStatusVote
+        createWithDirKeyCertificateVersionLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirKeyCertificateVersionLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String fingerprintLine = "fingerprint "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C";
+    private static RelayNetworkStatusVote
+        createWithFingerprintLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.fingerprintLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirKeyPublishedLine = "dir-key-published 2011-04-27 "
+        + "05:34:37";
+    private static RelayNetworkStatusVote
+        createWithDirKeyPublishedLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirKeyPublishedLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirKeyExpiresLine = "dir-key-expires 2012-04-27 "
+        + "05:34:37";
+    private static RelayNetworkStatusVote
+        createWithDirKeyExpiresLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirKeyExpiresLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirIdentityKeyLines = "dir-identity-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIIBigKCAYEAtKpuLgVK25sfScjsxfVU1ljofrDygt9GP7bNJl/rghX42KUT97"
+        + "5W\nrGp/fbhF7p+FcKCzNOhJFINQbRf/5E3lN8mzoamIU43QqQ9RRVf94688Us"
+        + "azVsAN\nNVT0v9J0cr387WePjenRuIE1MmiP0nmw/XdvbPTayqax7VYlcUMXGH"
+        + "l8DnWix1EN\nRwmeig+JBte0JS12oo2HG9zcSfjLJVjY6ZmvRrVycXiRxGc/Jg"
+        + "NlSrV4cxUNykaB\nJ6pO6J499OZfQu7m1vAPTENrVJ4yEfRGRwFIY+d/s8BkKc"
+        + "aiWtXAfTe31uBI6GEH\nmS3HNu1JVSuoaUiQIvVYDLMfBvMcNyAx97UT1l6E0T"
+        + "n6a7pgChrquGwXai1xGzk8\n58aXwdSFoFBSTCkyemopq5H20p/nkPAO0pHL1k"
+        + "TvcaKz9CEj4XcKm+kOmzejYmIa\nkbWNcRpXPiUZ+xmwGtsq30xrzqiONmERkx"
+        + "qlmf7bVQPFvh3Kz6hGcmTBhTbHSe9h\nzDgmdaTNn3EHAgMBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----";
+    private static RelayNetworkStatusVote
+        createWithDirIdentityKeyLines(String lines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirIdentityKeyLines = lines;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirSigningKeyLines = "dir-signing-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIGJAoGBAN05qyHFQlTqykMP8yLuD4G2UuYulD4Xs8iSX5uqF+WGsUA1E4zZh4"
+        + "8h\nDFj8+drFiCu3EqhMEmVG4ACtJK2uz6D1XohUsbPWTR6LSnWJ8q6/zfTSLu"
+        + "mBGsN7\nPUXyMNjwRKL6UvrcbYk1d2mRBLO7SAP/sFW5fHhIBVeLIWrzQ19rAg"
+        + "MBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----";
+    private static RelayNetworkStatusVote
+        createWithDirSigningKeyLines(String lines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirSigningKeyLines = lines;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirKeyCrosscertLines = "dir-key-crosscert\n"
+        + "-----BEGIN ID SIGNATURE-----\n"
+        + "rPBFn6IJ6TvAHj4pSwlg+RTn1fP89JGSVa08wuyJr5dAvZsdakQXvRjamT9oJU"
+        + "aZ\nnY5Rl/tRlGuSQ0BglTPPKoXdKERK0FUr9f0EKrQy7NDUgE2j9losiRuyKz"
+        + "hA3neZ\nK4yF8bhqAwM51u7fzAhIjNeRif9c04rhFJJCseco84w=\n"
+        + "-----END ID SIGNATURE-----";
+    private static RelayNetworkStatusVote
+        createWithDirKeyCrosscertLines(String lines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirKeyCrosscertLines = lines;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String dirKeyCertificationLines = "dir-key-certification\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "hPSh6FuohNF5ccjiMbkvr8cZJwGFuL11cNtwN9k0X3pUdFZVATIEkqBe7z+rE2"
+        + "PX\nPw+BGyC6wYAieoTVIhLpwKqd7DXLYjuhPZ28+7MQaDL01AqYeRp5PT01Px"
+        + "rFY0Um\nlVf95uqUitgvDT76Ne4ExWk6UvGlYB9OBgBySZz8VWe9znoMqb0uHn"
+        + "/p8IzqTApT\nAxRWXBHClntMeRqtGxaj8DcdJFn8yMxQiZG7MfDg2sq2ySPJyG"
+        + "lN+neoVDVhZiDI\n9LTNmw60gWlUp2erFeam8Mo1ZBC4DPNjQEm6QeHZFZMkhD"
+        + "uO6SwS/FL712A42+Co\nYtMaVot/p5FG2ZSBXbgl2XP5/z8ELnpmXqMbPAoWRo"
+        + "3BPNSJkIQQNog8Q5ZrK+av\nZDw5eGPltGKsXOkvuzIMM8nBeAnDPDgYvzrIFO"
+        + "bEGbvY/P8mzVAZxp3Yz+sRtNel\nC1SWz/Fx+Saex5oI7DJ3xtSD4XqKb/wYwZ"
+        + "FT8IxDYq1t2tFXdHxd4QPRVcvc0zYC\n"
+        + "-----END SIGNATURE-----";
+    private static RelayNetworkStatusVote
+        createWithDirKeyCertificationLines(String lines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.dirKeyCertificationLines = lines;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private List<String> statusEntries = null;
+    private static RelayNetworkStatusVote createWithStatusEntries(
+        List<String> statusEntries) throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.statusEntries = statusEntries;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String directoryFooterLine = "directory-footer";
+    private static RelayNetworkStatusVote
+        createWithDirectoryFooterLine(String line)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.directoryFooterLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String directorySignatureLines = "directory-signature "
+          + "80550987E1D626E3EBA5E5E75A458DE0626D088C "
+          + "EEB9299D295C1C815E289FBF2F2BBEA5F52FDD19\n"
+          + "-----BEGIN SIGNATURE-----\n"
+          + "iHEU3Iidya5RIrjyYgv8tlU0R+rF56/3/MmaaZi0a67e7ZkISfQ4dghScHxn"
+          + "F3Yh\nrXVaaoP07r6Ta+s0g1Zijm3lms50Nk/4tV2p8Y63c3F4Q3DAnK40Oi"
+          + "kfOIwEj+Ny\n+zBRQssP3hPhTPOj/A7o3mZZwtL6x1sxpeu/nME1l5E=\n"
+          + "-----END SIGNATURE-----";
+    private static RelayNetworkStatusVote
+        createWithDirectorySignatureLines(String lines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.directorySignatureLines = lines;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    }
+    private String unrecognizedHeaderLine = null;
+    protected static RelayNetworkStatusVote
+        createWithUnrecognizedHeaderLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.unrecognizedHeaderLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
+          failUnrecognizedDescriptorLines);
+    }
+    private String unrecognizedDirSourceLine = null;
+    protected static RelayNetworkStatusVote
+        createWithUnrecognizedDirSourceLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.unrecognizedDirSourceLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
+          failUnrecognizedDescriptorLines);
+    }
+    private String unrecognizedStatusEntryLine = null;
+    protected static RelayNetworkStatusVote
+        createWithUnrecognizedStatusEntryLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.unrecognizedStatusEntryLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
+          failUnrecognizedDescriptorLines);
+    }
+    private String unrecognizedFooterLine = null;
+    protected static RelayNetworkStatusVote
+        createWithUnrecognizedFooterLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.unrecognizedFooterLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
+          failUnrecognizedDescriptorLines);
+    }
+    private String unrecognizedDirectorySignatureLine = null;
+    protected static RelayNetworkStatusVote
+        createWithUnrecognizedDirectorySignatureLine(String line,
+        boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      VoteBuilder vb = new VoteBuilder();
+      vb.unrecognizedDirectorySignatureLine = line;
+      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
+          failUnrecognizedDescriptorLines);
+    }
+
+    private VoteBuilder() {
+      if (this.statusEntries != null) {
+        return;
+      }
+      this.statusEntries = new ArrayList<>();
+      this.statusEntries.add("r right2privassy3 "
+          + "ADQ6gCT3DiFHKPDFr3rODBUI8HM lJY5Vf7kXec+VdkGW2flEsfkFC8 "
+          + "2011-11-12 00:03:40 50.63.8.215 9023 0\n"
+          + "s Exit Fast Guard Running Stable Valid\n"
+          + "opt v Tor 0.2.1.29 (r8e9b25e6c7a2e70c)\n"
+          + "w Bandwidth=297 Measured=73\n"
+          + "p accept 80,1194,1220,1293,1500,1533,1677,1723,1863,"
+          + "2082-2083,2086-2087,2095-2096,2102-2104,3128,3389,3690,4321,"
+          + "4643,5050,5190,5222-5223,5228,5900,6660-6669,6679,6697,8000,"
+          + "8008,8074,8080,8087-8088,8443,8888,9418,9999-10000,19294,"
+          + "19638\n"
+          + "m 8,9,10,11 "
+          + "sha256=9ciEx9t0McXk9A06I7qwN7pxuNOdpCP64RV/6cx2Zkc");
+    }
+    private byte[] buildVote() {
+      StringBuilder sb = new StringBuilder();
+      this.appendHeader(sb);
+      this.appendDirSource(sb);
+      this.appendStatusEntries(sb);
+      this.appendFooter(sb);
+      this.appendDirectorySignature(sb);
+      return sb.toString().getBytes();
+    }
+    private void appendHeader(StringBuilder sb) {
+      if (this.networkStatusVersionLine != null) {
+        sb.append(this.networkStatusVersionLine).append("\n");
+      }
+      if (this.voteStatusLine != null) {
+        sb.append(this.voteStatusLine).append("\n");
+      }
+      if (this.consensusMethodsLine != null) {
+        sb.append(this.consensusMethodsLine).append("\n");
+      }
+      if (this.publishedLine != null) {
+        sb.append(this.publishedLine).append("\n");
+      }
+      if (this.validAfterLine != null) {
+        sb.append(this.validAfterLine).append("\n");
+      }
+      if (this.freshUntilLine != null) {
+        sb.append(this.freshUntilLine).append("\n");
+      }
+      if (this.validUntilLine != null) {
+        sb.append(this.validUntilLine).append("\n");
+      }
+      if (this.votingDelayLine != null) {
+        sb.append(this.votingDelayLine).append("\n");
+      }
+      if (this.clientVersionsLine != null) {
+        sb.append(this.clientVersionsLine).append("\n");
+      }
+      if (this.serverVersionsLine != null) {
+        sb.append(this.serverVersionsLine).append("\n");
+      }
+      if (this.packageLines != null) {
+        sb.append(this.packageLines).append("\n");
+      }
+      if (this.knownFlagsLine != null) {
+        sb.append(this.knownFlagsLine).append("\n");
+      }
+      if (this.flagThresholdsLine != null) {
+        sb.append(this.flagThresholdsLine).append("\n");
+      }
+      if (this.paramsLine != null) {
+        sb.append(this.paramsLine).append("\n");
+      }
+      if (this.unrecognizedHeaderLine != null) {
+        sb.append(this.unrecognizedHeaderLine).append("\n");
+      }
+    }
+    private void appendDirSource(StringBuilder sb) {
+      if (this.dirSourceLine != null) {
+        sb.append(this.dirSourceLine).append("\n");
+      }
+      if (this.contactLine != null) {
+        sb.append(this.contactLine).append("\n");
+      }
+      if (this.legacyDirKeyLine != null) {
+        sb.append(this.legacyDirKeyLine).append("\n");
+      }
+      if (this.dirKeyCertificateVersionLine != null) {
+        sb.append(this.dirKeyCertificateVersionLine).append("\n");
+      }
+      if (this.fingerprintLine != null) {
+        sb.append(this.fingerprintLine).append("\n");
+      }
+      if (this.dirKeyPublishedLine != null) {
+        sb.append(this.dirKeyPublishedLine).append("\n");
+      }
+      if (this.dirKeyExpiresLine != null) {
+        sb.append(this.dirKeyExpiresLine).append("\n");
+      }
+      if (this.dirIdentityKeyLines != null) {
+        sb.append(this.dirIdentityKeyLines).append("\n");
+      }
+      if (this.dirSigningKeyLines != null) {
+        sb.append(this.dirSigningKeyLines).append("\n");
+      }
+      if (this.dirKeyCrosscertLines != null) {
+        sb.append(this.dirKeyCrosscertLines).append("\n");
+      }
+      if (this.dirKeyCertificationLines != null) {
+        sb.append(this.dirKeyCertificationLines).append("\n");
+      }
+      if (this.unrecognizedDirSourceLine != null) {
+        sb.append(this.unrecognizedDirSourceLine).append("\n");
+      }
+    }
+    private void appendStatusEntries(StringBuilder sb) {
+      for (String statusEntry : this.statusEntries) {
+        sb.append(statusEntry).append("\n");
+      }
+      if (this.unrecognizedStatusEntryLine != null) {
+        sb.append(this.unrecognizedStatusEntryLine).append("\n");
+      }
+    }
+    private void appendFooter(StringBuilder sb) {
+      if (this.directoryFooterLine != null) {
+        sb.append(this.directoryFooterLine).append("\n");
+      }
+      if (this.unrecognizedFooterLine != null) {
+        sb.append(this.unrecognizedFooterLine).append("\n");
+      }
+    }
+    private void appendDirectorySignature(StringBuilder sb) {
+      if (this.directorySignatureLines != null) {
+        sb.append(directorySignatureLines).append("\n");
+      }
+      if (this.unrecognizedDirectorySignatureLine != null) {
+        sb.append(this.unrecognizedDirectorySignatureLine).append("\n");
+      }
+    }
+  }
+
+  @Test()
+  public void testSampleVote() throws DescriptorParseException {
+    VoteBuilder vb = new VoteBuilder();
+    RelayNetworkStatusVote vote =
+        new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    assertEquals(3, vote.getNetworkStatusVersion());
+    List<Integer> consensusMethods = Arrays.asList(
+        new Integer[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11});
+    assertEquals(vote.getConsensusMethods(), consensusMethods);
+    assertEquals(1322643001000L, vote.getPublishedMillis());
+    assertEquals(1322643600000L, vote.getValidAfterMillis());
+    assertEquals(1322647200000L, vote.getFreshUntilMillis());
+    assertEquals(1322654400000L, vote.getValidUntilMillis());
+    assertEquals(300L, vote.getVoteSeconds());
+    assertEquals(300L, vote.getDistSeconds());
+    assertTrue(vote.getKnownFlags().contains("Running"));
+    assertEquals(30000, (int) vote.getConsensusParams().get(
+        "CircuitPriorityHalflifeMsec"));
+    assertEquals("Tor 0.2.1.29 (r8e9b25e6c7a2e70c)",
+        vote.getStatusEntry("00343A8024F70E214728F0C5AF7ACE0C1508F073").
+        getVersion());
+    assertEquals(3, vote.getDirKeyCertificateVersion());
+    assertEquals("80550987E1D626E3EBA5E5E75A458DE0626D088C",
+        vote.getIdentity());
+    assertEquals(1303882477000L, /* 2011-04-27 05:34:37 */
+        vote.getDirKeyPublishedMillis());
+    assertEquals(1335504877000L, /* 2012-04-27 05:34:37 */
+        vote.getDirKeyExpiresMillis());
+    assertEquals("-----BEGIN RSA PUBLIC KEY-----",
+        vote.getDirIdentityKey().split("\n")[0]);
+    assertEquals("-----BEGIN RSA PUBLIC KEY-----",
+        vote.getDirSigningKey().split("\n")[0]);
+    assertEquals("-----BEGIN ID SIGNATURE-----",
+        vote.getDirKeyCrosscert().split("\n")[0]);
+    assertEquals("-----BEGIN SIGNATURE-----",
+        vote.getDirKeyCertification().split("\n")[0]);
+    assertEquals(1, vote.getSignatures().size());
+    DirectorySignature signature = vote.getSignatures().get(0);
+    assertEquals("sha1", signature.getAlgorithm());
+    assertEquals("80550987E1D626E3EBA5E5E75A458DE0626D088C",
+        signature.getIdentity());
+    assertEquals("EEB9299D295C1C815E289FBF2F2BBEA5F52FDD19",
+        signature.getSigningKeyDigest());
+    assertEquals("-----BEGIN SIGNATURE-----\n"
+        + "iHEU3Iidya5RIrjyYgv8tlU0R+rF56/3/MmaaZi0a67e7ZkISfQ4dghScHxn"
+        + "F3Yh\nrXVaaoP07r6Ta+s0g1Zijm3lms50Nk/4tV2p8Y63c3F4Q3DAnK40Oi"
+        + "kfOIwEj+Ny\n+zBRQssP3hPhTPOj/A7o3mZZwtL6x1sxpeu/nME1l5E=\n"
+        + "-----END SIGNATURE-----\n", signature.getSignature());
+    assertTrue(vote.getUnrecognizedLines().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNoLine()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNewLine()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version 3\n");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNewLineSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version 3\n ");
+  }
+
+  @Test()
+  public void testNetworkStatusVersionPrefixLineAtChar()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "@vote\nnetwork-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionPrefixLine()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "directory-footer\nnetwork-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionPrefixLinePoundChar()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "#vote\nnetwork-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionNoSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionOneSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersion42()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version 42");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionFourtyTwo()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        "network-status-version FourtyTwo");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusNoLine() throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNetworkStatusVersionSpaceBefore()
+      throws DescriptorParseException {
+    VoteBuilder.createWithNetworkStatusVersionLine(
+        " network-status-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusSpaceBefore() throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine(" vote-status vote");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusNoSpace() throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine("vote-status");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusOneSpace() throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine("vote-status ");
+  }
+
+  @Test()
+  public void testVoteStatusVoteOneSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine("vote-status vote ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusConsensus() throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine("vote-status consensus");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVoteStatusTheMagicVoteStatus()
+      throws DescriptorParseException {
+    VoteBuilder.createWithVoteStatusLine(
+        "vote-status TheMagicVoteStatus");
+  }
+
+  @Test()
+  public void testConsensusMethodNoLine()
+      throws DescriptorParseException {
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithConsensusMethodsLine(null);
+    assertNull(vote.getConsensusMethods());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodNoSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithConsensusMethodsLine("consensus-methods");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodOneSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithConsensusMethodsLine("consensus-methods ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodEleven()
+      throws DescriptorParseException {
+    VoteBuilder.createWithConsensusMethodsLine(
+        "consensus-methods eleven");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodMinusOne()
+      throws DescriptorParseException {
+    VoteBuilder.createWithConsensusMethodsLine("consensus-methods -1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodNinePeriod()
+      throws DescriptorParseException {
+    VoteBuilder.createWithConsensusMethodsLine("consensus-methods "
+        + "999999999999999999999999999999999999999999999999999999999999");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testConsensusMethodTwoLines()
+      throws DescriptorParseException {
+    VoteBuilder.createWithConsensusMethodsLine(
+        "consensus-method 1\nconsensus-method 1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublishedNoLine() throws DescriptorParseException {
+    VoteBuilder.createWithPublishedLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterNoLine() throws DescriptorParseException {
+    VoteBuilder.createWithValidAfterLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterNoSpace() throws DescriptorParseException {
+    VoteBuilder.createWithValidAfterLine("valid-after");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterOneSpace() throws DescriptorParseException {
+    VoteBuilder.createWithValidAfterLine("valid-after ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterLongAgo() throws DescriptorParseException {
+    VoteBuilder.createWithValidAfterLine("valid-after long ago");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidAfterFeb30() throws DescriptorParseException {
+    VoteBuilder.createWithValidAfterLine(
+        "valid-after 2011-02-30 09:00:00");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFreshUntilNoLine() throws DescriptorParseException {
+    VoteBuilder.createWithFreshUntilLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFreshUntilAroundTen() throws DescriptorParseException {
+    VoteBuilder.createWithFreshUntilLine(
+        "fresh-until 2011-11-30 around ten");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testValidUntilTomorrowMorning()
+      throws DescriptorParseException {
+    VoteBuilder.createWithValidUntilLine(
+        "valid-until tomorrow morning");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayNoLine() throws DescriptorParseException {
+    VoteBuilder.createWithVotingDelayLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayNoSpace() throws DescriptorParseException {
+    VoteBuilder.createWithVotingDelayLine("voting-delay");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayOneSpace() throws DescriptorParseException {
+    VoteBuilder.createWithVotingDelayLine("voting-delay ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayTriple() throws DescriptorParseException {
+    VoteBuilder.createWithVotingDelayLine(
+        "voting-delay 300 300 300");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelaySingle() throws DescriptorParseException {
+    VoteBuilder.createWithVotingDelayLine("voting-delay 300");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testVotingDelayOneTwo() throws DescriptorParseException {
+    VoteBuilder.createWithVotingDelayLine("voting-delay one two");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testClientVersionsComma() throws DescriptorParseException {
+    VoteBuilder.createWithClientVersionsLine("client-versions ,");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testClientVersionsCommaVersion()
+      throws DescriptorParseException {
+    VoteBuilder.createWithClientVersionsLine(
+        "client-versions ,0.2.2.34");
+  }
+
+  @Test()
+  public void testPackageNone() throws DescriptorParseException {
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithPackageLines(null);
+    assertNull(vote.getPackageLines());
+  }
+
+  @Test()
+  public void testPackageOne() throws DescriptorParseException {
+    String packageLine = "package shouldbesecond 0 http digest=digest";
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithPackageLines(packageLine);
+    assertEquals(packageLine.substring("package ".length()),
+        vote.getPackageLines().get(0));
+  }
+
+  @Test()
+  public void testPackageTwo() throws DescriptorParseException {
+    List<String> packageLines = Arrays.asList(
+        "package shouldbesecond 0 http digest=digest",
+        "package outoforder 0 http digest=digest");
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithPackageLines(packageLines.get(0)
+        + "\n" + packageLines.get(1));
+    for (int i = 0; i < packageLines.size(); i++) {
+      assertEquals(packageLines.get(i).substring("package ".length()),
+          vote.getPackageLines().get(i));
+    }
+  }
+
+   @Test(expected = DescriptorParseException.class)
+   public void testPackageIncomplete() throws DescriptorParseException {
+     String packageLine = "package shouldbesecond 0 http";
+     ConsensusBuilder.createWithPackageLines(packageLine);
+   }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testKnownFlagsNoLine() throws DescriptorParseException {
+    VoteBuilder.createWithKnownFlagsLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testKnownFlagsNoSpace() throws DescriptorParseException {
+    VoteBuilder.createWithKnownFlagsLine("known-flags");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testKnownFlagsOneSpace() throws DescriptorParseException {
+    VoteBuilder.createWithKnownFlagsLine("known-flags ");
+  }
+
+  @Test()
+  public void testFlagThresholdsLine() throws DescriptorParseException {
+    VoteBuilder vb = new VoteBuilder();
+    RelayNetworkStatusVote vote =
+        new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+    assertEquals(693369L, vote.getStableUptime());
+    assertEquals(153249L, vote.getStableMtbf());
+    assertEquals(40960L, vote.getFastBandwidth());
+    assertEquals(94.669, vote.getGuardWfu(), 0.001);
+    assertEquals(691200L, vote.getGuardTk());
+    assertEquals(174080L, vote.getGuardBandwidthIncludingExits());
+    assertEquals(184320L, vote.getGuardBandwidthExcludingExits());
+    assertEquals(1, vote.getEnoughMtbfInfo());
+  }
+
+  @Test()
+  public void testFlagThresholdsNoLine() throws DescriptorParseException {
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithFlagThresholdsLine(null);
+    assertEquals(-1L, vote.getStableUptime());
+    assertEquals(-1L, vote.getStableMtbf());
+    assertEquals(-1L, vote.getFastBandwidth());
+    assertEquals(-1.0, vote.getGuardWfu(), 0.001);
+    assertEquals(-1L, vote.getGuardTk());
+    assertEquals(-1L, vote.getGuardBandwidthIncludingExits());
+    assertEquals(-1L, vote.getGuardBandwidthExcludingExits());
+    assertEquals(-1, vote.getEnoughMtbfInfo());
+  }
+
+  @Test()
+  public void testFlagThresholdsAllZeroes()
+      throws DescriptorParseException {
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithFlagThresholdsLine("flag-thresholds "
+            + "stable-uptime=0 stable-mtbf=0 fast-speed=0 guard-wfu=0.0% "
+            + "guard-tk=0 guard-bw-inc-exits=0 guard-bw-exc-exits=0 "
+            + "enough-mtbf=0");
+    assertEquals(0L, vote.getStableUptime());
+    assertEquals(0L, vote.getStableMtbf());
+    assertEquals(0L, vote.getFastBandwidth());
+    assertEquals(0.0, vote.getGuardWfu(), 0.001);
+    assertEquals(0L, vote.getGuardTk());
+    assertEquals(0L, vote.getGuardBandwidthIncludingExits());
+    assertEquals(0L, vote.getGuardBandwidthExcludingExits());
+    assertEquals(0, vote.getEnoughMtbfInfo());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFlagThresholdsNoSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithFlagThresholdsLine("flag-thresholds");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFlagThresholdsOneSpace()
+      throws DescriptorParseException {
+    VoteBuilder.createWithFlagThresholdsLine("flag-thresholds ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFlagThresholdDuplicate()
+      throws DescriptorParseException {
+    VoteBuilder vb = new VoteBuilder();
+    vb.flagThresholdsLine = vb.flagThresholdsLine + "\n"
+        + vb.flagThresholdsLine;
+    new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameMissing() throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source  "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameTooLong() throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source "
+        + "urrassssssssssssssssssssssssssssssssssssssssssssssss "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameIllegalCharacters()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urra$ "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test()
+  public void testFingerprintLowerCase() throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987e1d626e3eba5e5e75a458de0626d088c 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooShort() throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooLong() throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C8055 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintIllegalCharacters()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "ABCDEFGHIJKLM6E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + " 208.83.223.34 208.83.223.34 443 80");
+  }
+
+  @Test()
+  public void testHostname256()
+      throws DescriptorParseException {
+    /* This test doesn't fail, because we're not parsing the hostname. */
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 256.256.256.256 "
+        + "208.83.223.34 443 80");
+    assertEquals("256.256.256.256", vote.getHostname());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testHostnameMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C  208.83.223.34 443 "
+        + "80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddress256()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "256.256.256.256 443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddressMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34  443 "
+        + "80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirPortMinus443()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 -443 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirPortFourFourThree()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 four-four-three 80");
+  }
+
+  @Test()
+  public void testDirPort0() throws DescriptorParseException {
+    /* This test doesn't fail, because we're accepting DirPort 0, even
+     * though it doesn't make sense from Tor's view. */
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 0 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPortMissing() throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 ");
+  }
+
+  @Test()
+  public void testDirPortOrPortIdentical()
+      throws DescriptorParseException {
+    /* This test doesn't fail, even though identical OR and Dir port don't
+     * make much sense from Tor's view. */
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 80 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceLineMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSourceLineDuplicate()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSourceLine("dir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80\ndir-source urras "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
+        + "208.83.223.34 443 80");
+  }
+
+  @Test()
+  public void testContactLineMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithContactLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testContactLineDuplicate()
+      throws DescriptorParseException {
+    VoteBuilder.createWithContactLine("contact 4096R/E012B42D Jacob "
+        + "Appelbaum <jacob@xxxxxxxxxxxxx>\ncontact 4096R/E012B42D Jacob "
+        + "Appelbaum <jacob@xxxxxxxxxxxxx>");
+  }
+
+  @Test()
+  public void testLegacyDirKeyLine() throws DescriptorParseException {
+    RelayNetworkStatusVote vote = VoteBuilder.createWithLegacyDirKeyLine(
+        "legacy-dir-key 81349FC1F2DBA2C2C11B45CB9706637D480AB913");
+    assertEquals("81349FC1F2DBA2C2C11B45CB9706637D480AB913",
+        vote.getLegacyDirKey());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testLegacyDirKeyLineNoId() throws DescriptorParseException {
+    VoteBuilder.createWithLegacyDirKeyLine("legacy-dir-key ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyCertificateVersionLineMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyCertificateVersionLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyCertificateVersionLineDuplicate()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyCertificateVersionLine(
+        "dir-key-certificate-version 3\ndir-key-certificate-version 3");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintLineMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithFingerprintLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintLineDuplicate()
+      throws DescriptorParseException {
+    VoteBuilder.createWithFingerprintLine("fingerprint "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C\nfingerprint "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintLineTooLong()
+      throws DescriptorParseException {
+    VoteBuilder.createWithFingerprintLine("fingerprint "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D088C8055");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintLineTooShort()
+      throws DescriptorParseException {
+    VoteBuilder.createWithFingerprintLine("fingerprint "
+        + "80550987E1D626E3EBA5E5E75A458DE0626D");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyPublished3011()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyPublishedLine("dir-key-published "
+        + "3011-04-27 05:34:37");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyPublishedRecentlyAtNoon()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyPublishedLine("dir-key-published "
+        + "recently 12:00:00");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyPublishedRecentlyNoTime()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyPublishedLine("dir-key-published "
+        + "recently");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyExpiresSoonAtNoon()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyExpiresLine("dir-key-expires "
+        + "soon 12:00:00");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyExpiresLineMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyExpiresLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyExpiresLineDuplicate()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyExpiresLine("dir-key-expires 2012-04-27 "
+        + "05:34:37\ndir-key-expires 2012-04-27 05:34:37");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirIdentityKeyLinesMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirIdentityKeyLines(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirSigningKeyLinesMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirSigningKeyLines(null);
+  }
+
+  @Test()
+  public void testDirKeyCrosscertLinesMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyCrosscertLines(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirKeyCertificationLinesMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirKeyCertificationLines(null);
+  }
+
+  @Test()
+  public void testDirectoryFooterLineMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirectoryFooterLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirectorySignaturesLinesMissing()
+      throws DescriptorParseException {
+    VoteBuilder.createWithDirectorySignatureLines(null);
+  }
+
+  @Test()
+  public void testDirectorySignaturesLinesTwoAlgorithms()
+      throws DescriptorParseException {
+    String identitySha256 = "32519E5CB7254AB5A94CC9925EC7676E53D5D52EEAB7"
+        + "914BD3ED751E537CAFCC";
+    String signingKeyDigestSha256 = "5A59D99C17831B9254422B6C5AA10CC59381"
+        + "6CAA5241E22ECAE8BBB4E8E9D1FC";
+    String signatureSha256 = "-----BEGIN SIGNATURE-----\n"
+        + "x57Alc424/zHS73SHokghGtNBVrBjtUz+gSL5w9AHGKUQcMyfw4Z9aDlKpTbFc"
+        + "5W\nnyIvFmM9C2OAH0S1+a647HHIxhE0zKf4+yKSwzqSyL6sbKQygVlJsRHNRr"
+        + "cFg8lp\nqBxEwvxQoA4xEDqnerR92pbK9l42nNLiKOcoReUqbbQ=\n"
+        + "-----END SIGNATURE-----";
+    String identitySha1 = "80550987E1D626E3EBA5E5E75A458DE0626D088C";
+    String signingKeyDigestSha1 =
+        "EEB9299D295C1C815E289FBF2F2BBEA5F52FDD19";
+    String signatureSha1 = "-----BEGIN SIGNATURE-----\n"
+        + "iHEU3Iidya5RIrjyYgv8tlU0R+rF56/3/MmaaZi0a67e7ZkISfQ4dghScHxnF3"
+        + "Yh\nrXVaaoP07r6Ta+s0g1Zijm3lms50Nk/4tV2p8Y63c3F4Q3DAnK40OikfOI"
+        + "wEj+Ny\n+zBRQssP3hPhTPOj/A7o3mZZwtL6x1sxpeu/nME1l5E=\n"
+        + "-----END SIGNATURE-----";
+    String signaturesLines = String.format(
+        "directory-signature sha256 %s %s\n%s\n"
+        + "directory-signature %s %s\n%s", identitySha256,
+        signingKeyDigestSha256, signatureSha256, identitySha1,
+        signingKeyDigestSha1, signatureSha1);
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithDirectorySignatureLines(signaturesLines);
+    assertEquals(2, vote.getSignatures().size());
+    DirectorySignature firstSignature = vote.getSignatures().get(0);
+    assertEquals("sha256", firstSignature.getAlgorithm());
+    assertEquals(identitySha256, firstSignature.getIdentity());
+    assertEquals(signingKeyDigestSha256,
+        firstSignature.getSigningKeyDigest());
+    assertEquals(signatureSha256 + "\n", firstSignature.getSignature());
+    DirectorySignature secondSignature = vote.getSignatures().get(1);
+    assertEquals("sha1", secondSignature.getAlgorithm());
+    assertEquals(identitySha1, secondSignature.getIdentity());
+    assertEquals(signingKeyDigestSha1,
+        secondSignature.getSigningKeyDigest());
+    assertEquals(signatureSha1 + "\n", secondSignature.getSignature());
+    assertEquals(signingKeyDigestSha1, vote.getSigningKeyDigest());
+  }
+
+  @Test()
+  public void testDirectorySignaturesLinesTwoAlgorithmsSameDigests()
+      throws DescriptorParseException {
+    String signaturesLines = "directory-signature 00 00\n"
+        + "-----BEGIN SIGNATURE-----\n00\n-----END SIGNATURE-----\n"
+        + "directory-signature sha256 00 00\n"
+        + "-----BEGIN SIGNATURE-----\n00\n-----END SIGNATURE-----";
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithDirectorySignatureLines(signaturesLines);
+    assertEquals(2, vote.getSignatures().size());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedHeaderLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    VoteBuilder.createWithUnrecognizedHeaderLine(unrecognizedLine, true);
+  }
+
+  @Test()
+  public void testUnrecognizedHeaderLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusVote vote = VoteBuilder.
+        createWithUnrecognizedHeaderLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, vote.getUnrecognizedLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedDirSourceLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    VoteBuilder.createWithUnrecognizedDirSourceLine(unrecognizedLine,
+        true);
+  }
+
+  @Test()
+  public void testUnrecognizedDirSourceLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusVote vote = VoteBuilder.
+        createWithUnrecognizedDirSourceLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, vote.getUnrecognizedLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedFooterLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    VoteBuilder.createWithUnrecognizedFooterLine(unrecognizedLine, true);
+  }
+
+  @Test()
+  public void testUnrecognizedFooterLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    RelayNetworkStatusVote vote = VoteBuilder.
+        createWithUnrecognizedFooterLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, vote.getUnrecognizedLines());
+  }
+
+  @Test()
+  public void testIdEd25519MasterKey()
+      throws DescriptorParseException {
+    String masterKey25519 = "8RH34kO07Pp+XYwzdoATVyCibIvmbslUjRkAm7J4IA8";
+    List<String> statusEntries = new ArrayList<>();
+    statusEntries.add("r PDrelay1 AAFJ5u9xAqrKlpDW6N0pMhJLlKs "
+        + "bgJiI/la3e9u0K7cQ5pMSXhigHI 2015-12-01 04:54:30 95.215.44.189 "
+        + "8080 0\n"
+        + "id ed25519 " + masterKey25519);
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithStatusEntries(statusEntries);
+    String fingerprint = vote.getStatusEntries().firstKey();
+    assertEquals(masterKey25519,
+        vote.getStatusEntry(fingerprint).getMasterKeyEd25519());
+  }
+
+  @Test()
+  public void testIdEd25519None()
+      throws DescriptorParseException {
+    List<String> statusEntries = new ArrayList<>();
+    statusEntries.add("r MathematicalApology AAPJIrV9nhfgTYQs0vsTghFaP2A "
+        + "uA7p0m68O8ILXsf3aLZUj0EvDNE 2015-12-01 18:01:49 172.99.69.177 "
+        + "443 9030\n"
+        + "id ed25519 none");
+    RelayNetworkStatusVote vote =
+        VoteBuilder.createWithStatusEntries(statusEntries);
+    String fingerprint = vote.getStatusEntries().firstKey();
+    assertEquals("none",
+        vote.getStatusEntry(fingerprint).getMasterKeyEd25519());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIdRsa1024None()
+      throws DescriptorParseException {
+    List<String> statusEntries = new ArrayList<>();
+    statusEntries.add("r MathematicalApology AAPJIrV9nhfgTYQs0vsTghFaP2A "
+        + "uA7p0m68O8ILXsf3aLZUj0EvDNE 2015-12-01 18:01:49 172.99.69.177 "
+        + "443 9030\n"
+        + "id rsa1024 none");
+    VoteBuilder.createWithStatusEntries(statusEntries);
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/ServerDescriptorImplTest.java b/src/test/java/org/torproject/descriptor/impl/ServerDescriptorImplTest.java
new file mode 100644
index 0000000..cd3f1a5
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/ServerDescriptorImplTest.java
@@ -0,0 +1,1605 @@
+/* Copyright 2012--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.SortedMap;
+
+import org.junit.Test;
+import org.torproject.descriptor.BandwidthHistory;
+import org.torproject.descriptor.ServerDescriptor;
+
+/* Test parsing of relay server descriptors. */
+public class ServerDescriptorImplTest {
+
+  /* Helper class to build a descriptor based on default data and
+   * modifications requested by test methods. */
+  private static class DescriptorBuilder {
+    private String routerLine = "router saberrider2008 94.134.192.243 "
+        + "9001 0 0";
+    private static ServerDescriptor createWithRouterLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.routerLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String bandwidthLine = "bandwidth 51200 51200 53470";
+    private static ServerDescriptor createWithBandwidthLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.bandwidthLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String platformLine = "platform Tor 0.2.2.35 "
+        + "(git-b04388f9e7546a9f) on Linux i686";
+    private static ServerDescriptor createWithPlatformLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.platformLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String publishedLine = "published 2012-01-01 04:03:19";
+    private static ServerDescriptor createWithPublishedLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.publishedLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String fingerprintLine = "opt fingerprint D873 3048 FC8E "
+        + "C910 2466 AD8F 3098 622B F1BF 71FD";
+    private static ServerDescriptor createWithFingerprintLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.fingerprintLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String hibernatingLine = null;
+    private static ServerDescriptor createWithHibernatingLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.hibernatingLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String uptimeLine = "uptime 48";
+    private static ServerDescriptor createWithUptimeLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.uptimeLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String onionKeyLines = "onion-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ1U4V9SeiKooSo5Bp"
+        + "PL\no3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3olIynCI4QryfCE"
+        + "uC3cTF\n9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKFfacOkpAg"
+        + "MBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----";
+    private static ServerDescriptor createWithOnionKeyLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.onionKeyLines = lines;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String signingKeyLines = "signing-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIGJAoGBALMm3r3QDh482Ewe6Ub9wvRIfmEkoNX6q5cEAtQRNHSDcNx41gjELb"
+        + "cl\nEniVMParBYACKfOxkS+mTTnIRDKVNEJTsDOwryNrc4X9JnPc/nn6ymYPiN"
+        + "DhUROG\n8URDIhQoixcUeyyrVB8sxliSstKimulGnB7xpjYOlO8JKaHLNL4TAg"
+        + "MBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----";
+    private static ServerDescriptor createWithSigningKeyLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.signingKeyLines = lines;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String onionKeyCrosscertLines = null;
+    private static ServerDescriptor createWithOnionKeyCrosscertLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.onionKeyCrosscertLines = lines;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String ntorOnionKeyCrosscertLines = null;
+    private static ServerDescriptor createWithNtorOnionKeyCrosscertLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.ntorOnionKeyCrosscertLines = lines;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String exitPolicyLines = "reject *:*";
+    private static ServerDescriptor createWithExitPolicyLines(
+        String lines) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.exitPolicyLines = lines;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String contactLine = "contact Random Person <nobody AT "
+        + "example dot com>";
+    private static ServerDescriptor createWithContactLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.contactLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String familyLine = null;
+    private static ServerDescriptor createWithFamilyLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.familyLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String readHistoryLine = null;
+    private static ServerDescriptor createWithReadHistoryLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.readHistoryLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String writeHistoryLine = null;
+    private static ServerDescriptor createWithWriteHistoryLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.writeHistoryLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String eventdnsLine = null;
+    private static ServerDescriptor createWithEventdnsLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.eventdnsLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String cachesExtraInfoLine = null;
+    private static ServerDescriptor createWithCachesExtraInfoLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.cachesExtraInfoLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String extraInfoDigestLine = "opt extra-info-digest "
+        + "1469D1550738A25B1E7B47CDDBCD7B2899F51B74";
+    private static ServerDescriptor createWithExtraInfoDigestLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.extraInfoDigestLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String hiddenServiceDirLine = "opt hidden-service-dir";
+    private static ServerDescriptor createWithHiddenServiceDirLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.hiddenServiceDirLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String protocolsLine = "opt protocols Link 1 2 Circuit 1";
+    private static ServerDescriptor createWithProtocolsLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.protocolsLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String allowSingleHopExitsLine = null;
+    private static ServerDescriptor
+        createWithAllowSingleHopExitsLine(String line)
+        throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.allowSingleHopExitsLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String ipv6PolicyLine = null;
+    private static ServerDescriptor createWithIpv6PolicyLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.ipv6PolicyLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String ntorOnionKeyLine = null;
+    private static ServerDescriptor createWithNtorOnionKeyLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.ntorOnionKeyLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String tunnelledDirServerLine = null;
+    private static ServerDescriptor createWithTunnelledDirServerLine(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.tunnelledDirServerLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String routerSignatureLines = "router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
+        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
+        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
+        + "-----END SIGNATURE-----";
+    private static ServerDescriptor createWithRouterSignatureLines(
+        String line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.routerSignatureLines = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private String unrecognizedLine = null;
+    private static ServerDescriptor createWithUnrecognizedLine(
+        String line, boolean failUnrecognizedDescriptorLines)
+        throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.unrecognizedLine = line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(),
+          failUnrecognizedDescriptorLines);
+    }
+    private byte[] nonAsciiLineBytes = null;
+    private static ServerDescriptor createWithNonAsciiLineBytes(
+        byte[] lineBytes, boolean failUnrecognizedDescriptorLines)
+            throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.nonAsciiLineBytes = lineBytes;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(),
+          failUnrecognizedDescriptorLines);
+    }
+    private String identityEd25519Lines = null,
+        masterKeyEd25519Line = null, routerSigEd25519Line = null;
+    private static ServerDescriptor createWithEd25519Lines(
+        String identityEd25519Lines, String masterKeyEd25519Line,
+        String routerSigEd25519Line) throws DescriptorParseException {
+      DescriptorBuilder db = new DescriptorBuilder();
+      db.identityEd25519Lines = identityEd25519Lines;
+      db.masterKeyEd25519Line = masterKeyEd25519Line;
+      db.routerSigEd25519Line = routerSigEd25519Line;
+      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    }
+    private byte[] buildDescriptor() {
+      StringBuilder sb = new StringBuilder();
+      if (this.routerLine != null) {
+        sb.append(this.routerLine).append("\n");
+      }
+      if (this.identityEd25519Lines != null) {
+        sb.append(this.identityEd25519Lines).append("\n");
+      }
+      if (this.masterKeyEd25519Line != null) {
+        sb.append(this.masterKeyEd25519Line).append("\n");
+      }
+      if (this.bandwidthLine != null) {
+        sb.append(this.bandwidthLine).append("\n");
+      }
+      if (this.platformLine != null) {
+        sb.append(this.platformLine).append("\n");
+      }
+      if (this.publishedLine != null) {
+        sb.append(this.publishedLine).append("\n");
+      }
+      if (this.fingerprintLine != null) {
+        sb.append(this.fingerprintLine).append("\n");
+      }
+      if (this.hibernatingLine != null) {
+        sb.append(this.hibernatingLine).append("\n");
+      }
+      if (this.uptimeLine != null) {
+        sb.append(this.uptimeLine).append("\n");
+      }
+      if (this.onionKeyLines != null) {
+        sb.append(this.onionKeyLines).append("\n");
+      }
+      if (this.signingKeyLines != null) {
+        sb.append(this.signingKeyLines).append("\n");
+      }
+      if (this.onionKeyCrosscertLines != null) {
+        sb.append(this.onionKeyCrosscertLines).append("\n");
+      }
+      if (this.ntorOnionKeyCrosscertLines != null) {
+        sb.append(this.ntorOnionKeyCrosscertLines).append("\n");
+      }
+      if (this.exitPolicyLines != null) {
+        sb.append(this.exitPolicyLines).append("\n");
+      }
+      if (this.contactLine != null) {
+        sb.append(this.contactLine).append("\n");
+      }
+      if (this.familyLine != null) {
+        sb.append(this.familyLine).append("\n");
+      }
+      if (this.readHistoryLine != null) {
+        sb.append(this.readHistoryLine).append("\n");
+      }
+      if (this.writeHistoryLine != null) {
+        sb.append(this.writeHistoryLine).append("\n");
+      }
+      if (this.eventdnsLine != null) {
+        sb.append(this.eventdnsLine).append("\n");
+      }
+      if (this.cachesExtraInfoLine != null) {
+        sb.append(this.cachesExtraInfoLine).append("\n");
+      }
+      if (this.extraInfoDigestLine != null) {
+        sb.append(this.extraInfoDigestLine).append("\n");
+      }
+      if (this.hiddenServiceDirLine != null) {
+        sb.append(this.hiddenServiceDirLine).append("\n");
+      }
+      if (this.protocolsLine != null) {
+        sb.append(this.protocolsLine).append("\n");
+      }
+      if (this.allowSingleHopExitsLine != null) {
+        sb.append(this.allowSingleHopExitsLine).append("\n");
+      }
+      if (this.ipv6PolicyLine != null) {
+        sb.append(this.ipv6PolicyLine).append("\n");
+      }
+      if (this.ntorOnionKeyLine != null) {
+        sb.append(this.ntorOnionKeyLine).append("\n");
+      }
+      if (this.tunnelledDirServerLine != null) {
+        sb.append(this.tunnelledDirServerLine).append("\n");
+      }
+      if (this.unrecognizedLine != null) {
+        sb.append(this.unrecognizedLine).append("\n");
+      }
+      if (this.nonAsciiLineBytes != null) {
+        try {
+          ByteArrayOutputStream baos = new ByteArrayOutputStream();
+          baos.write(sb.toString().getBytes());
+          baos.write(this.nonAsciiLineBytes);
+          baos.write("\n".getBytes());
+          if (this.routerSignatureLines != null) {
+            baos.write(this.routerSignatureLines.getBytes());
+          }
+          return baos.toByteArray();
+        } catch (IOException e) {
+          return null;
+        }
+      }
+      if (this.routerSigEd25519Line != null) {
+        sb.append(this.routerSigEd25519Line).append("\n");
+      }
+      if (this.routerSignatureLines != null) {
+        sb.append(this.routerSignatureLines).append("\n");
+      }
+      return sb.toString().getBytes();
+    }
+  }
+
+  @Test()
+  public void testSampleDescriptor() throws DescriptorParseException {
+    DescriptorBuilder db = new DescriptorBuilder();
+    ServerDescriptor descriptor =
+        new RelayServerDescriptorImpl(db.buildDescriptor(), true);
+    assertEquals("saberrider2008", descriptor.getNickname());
+    assertEquals("94.134.192.243", descriptor.getAddress());
+    assertEquals(9001, (int) descriptor.getOrPort());
+    assertEquals(0, (int) descriptor.getSocksPort());
+    assertEquals(0, (int) descriptor.getDirPort());
+    assertEquals("Tor 0.2.2.35 (git-b04388f9e7546a9f) on Linux i686",
+        descriptor.getPlatform());
+    assertEquals(Arrays.asList(new Integer[] {1, 2}),
+        descriptor.getLinkProtocolVersions());
+    assertEquals(Arrays.asList(new Integer[] {1}),
+        descriptor.getCircuitProtocolVersions());
+    assertEquals(1325390599000L, descriptor.getPublishedMillis());
+    assertEquals("D8733048FC8EC9102466AD8F3098622BF1BF71FD",
+        descriptor.getFingerprint());
+    assertEquals(48, descriptor.getUptime().longValue());
+    assertEquals(51200, (int) descriptor.getBandwidthRate());
+    assertEquals(51200, (int) descriptor.getBandwidthBurst());
+    assertEquals(53470, (int) descriptor.getBandwidthObserved());
+    assertEquals("1469D1550738A25B1E7B47CDDBCD7B2899F51B74",
+        descriptor.getExtraInfoDigest());
+    assertEquals(Arrays.asList(new Integer[] {2}),
+        descriptor.getHiddenServiceDirVersions());
+    assertEquals("Random Person <nobody AT example dot com>",
+        descriptor.getContact());
+    assertEquals(Arrays.asList(new String[] {"reject *:*"}),
+        descriptor.getExitPolicyLines());
+    assertFalse(descriptor.isHibernating());
+    assertNull(descriptor.getFamilyEntries());
+    assertNull(descriptor.getReadHistory());
+    assertNull(descriptor.getWriteHistory());
+    assertFalse(descriptor.getUsesEnhancedDnsLogic());
+    assertFalse(descriptor.getCachesExtraInfo());
+    assertFalse(descriptor.getAllowSingleHopExits());
+    assertTrue(descriptor.getUnrecognizedLines().isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testRouterLineMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine(null);
+  }
+
+  @Test()
+  public void testRouterOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithRouterLine("opt router saberrider2008 "
+        + "94.134.192.243 9001 0 0");
+    assertEquals("saberrider2008", descriptor.getNickname());
+    assertEquals("94.134.192.243", descriptor.getAddress());
+    assertEquals(9001, (int) descriptor.getOrPort());
+    assertEquals(0, (int) descriptor.getSocksPort());
+    assertEquals(0, (int) descriptor.getDirPort());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testRouterLinePrecedingHibernatingLine()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("hibernating 1\nrouter "
+        + "saberrider2008 94.134.192.243 9001 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router  94.134.192.243 9001 "
+        + "0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameInvalidChar() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router $aberrider2008 "
+        + "94.134.192.243 9001 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNicknameTooLong() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router "
+        + "saberrider2008ReallyLongNickname 94.134.192.243 9001 0 0");
+  }
+
+  @Test()
+  public void testNicknameTwoSpaces() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithRouterLine("router saberrider2008  "
+        + "94.134.192.243 9001 0 0");
+    assertEquals("saberrider2008", descriptor.getNickname());
+    assertEquals("94.134.192.243", descriptor.getAddress());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddress24() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "94.134.192/24 9001 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddress294() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "294.134.192.243 9001 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAddressMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008  9001 "
+        + "0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPort99001() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "94.134.192.243 99001 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPortMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "94.134.192.243  0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPortOne() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "94.134.192.243 one 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOrPortNewline() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "94.134.192.243 0\n 0 0");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testDirPortMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
+        + "94.134.192.243 9001 0 ");
+  }
+
+  @Test()
+  public void testPlatformMissing() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithPlatformLine(null);
+    assertNull(descriptor.getPlatform());
+  }
+
+  @Test()
+  public void testPlatformOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithPlatformLine("opt platform Tor 0.2.2.35 "
+        + "(git-b04388f9e7546a9f) on Linux i686");
+    assertEquals("Tor 0.2.2.35 (git-b04388f9e7546a9f) on Linux i686",
+        descriptor.getPlatform());
+  }
+
+  @Test()
+  public void testPlatformNoSpace() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithPlatformLine("platform");
+    assertEquals("", descriptor.getPlatform());
+  }
+
+  @Test()
+  public void testPlatformSpace() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithPlatformLine("platform ");
+    assertEquals("", descriptor.getPlatform());
+  }
+
+  @Test()
+  public void testProtocolsNoOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithProtocolsLine("protocols Link 1 2 Circuit 1");
+    assertEquals(Arrays.asList(new Integer[] {1, 2}),
+        descriptor.getLinkProtocolVersions());
+    assertEquals(Arrays.asList(new Integer[] {1}),
+        descriptor.getCircuitProtocolVersions());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testProtocolsAB() throws DescriptorParseException {
+    DescriptorBuilder.createWithProtocolsLine("opt protocols Link A B "
+        + "Circuit 1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testProtocolsNoCircuitVersions()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithProtocolsLine("opt protocols Link 1 2");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublishedMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithPublishedLine(null);
+  }
+
+  @Test()
+  public void testPublishedOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithPublishedLine("opt published 2012-01-01 04:03:19");
+    assertEquals(1325390599000L, descriptor.getPublishedMillis());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublished2039() throws DescriptorParseException {
+    DescriptorBuilder.createWithPublishedLine("published 2039-01-01 "
+        + "04:03:19");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublished1912() throws DescriptorParseException {
+    DescriptorBuilder.createWithPublishedLine("published 1912-01-01 "
+        + "04:03:19");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublishedFeb31() throws DescriptorParseException {
+    DescriptorBuilder.createWithPublishedLine("published 2012-02-31 "
+        + "04:03:19");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testPublishedNoTime() throws DescriptorParseException {
+    DescriptorBuilder.createWithPublishedLine("published 2012-01-01");
+  }
+
+  @Test()
+  public void testPublishedMillis() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithPublishedLine("opt published 2012-01-01 04:03:19.123");
+    assertEquals(1325390599000L, descriptor.getPublishedMillis());
+  }
+
+  @Test()
+  public void testFingerprintNoOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithFingerprintLine("fingerprint D873 3048 FC8E C910 2466 "
+            + "AD8F 3098 622B F1BF 71FD");
+    assertEquals("D8733048FC8EC9102466AD8F3098622BF1BF71FD",
+        descriptor.getFingerprint());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintG() throws DescriptorParseException {
+    DescriptorBuilder.createWithFingerprintLine("opt fingerprint G873 "
+        + "3048 FC8E C910 2466 AD8F 3098 622B F1BF 71FD");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooShort() throws DescriptorParseException {
+    DescriptorBuilder.createWithFingerprintLine("opt fingerprint D873 "
+        + "3048 FC8E C910 2466 AD8F 3098 622B F1BF");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintTooLong() throws DescriptorParseException {
+    DescriptorBuilder.createWithFingerprintLine("opt fingerprint D873 "
+        + "3048 FC8E C910 2466 AD8F 3098 622B F1BF 71FD D873");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFingerprintNoSpaces() throws DescriptorParseException {
+    DescriptorBuilder.createWithFingerprintLine("opt fingerprint "
+        + "D8733048FC8EC9102466AD8F3098622BF1BF71FD");
+  }
+
+  @Test()
+  public void testUptimeMissing() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithUptimeLine(null);
+    assertNull(descriptor.getUptime());
+  }
+
+  @Test()
+  public void testUptimeOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithUptimeLine("opt uptime 48");
+    assertEquals(48, descriptor.getUptime().longValue());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUptimeFourtyEight() throws DescriptorParseException {
+    DescriptorBuilder.createWithUptimeLine("uptime fourty-eight");
+  }
+
+  @Test()
+  public void testUptimeMinusOne() throws DescriptorParseException {
+    DescriptorBuilder.createWithUptimeLine("uptime -1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUptimeSpace() throws DescriptorParseException {
+    DescriptorBuilder.createWithUptimeLine("uptime ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUptimeNoSpace() throws DescriptorParseException {
+    DescriptorBuilder.createWithUptimeLine("uptime");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUptimeFourEight() throws DescriptorParseException {
+    DescriptorBuilder.createWithUptimeLine("uptime 4 8");
+  }
+
+  @Test()
+  public void testBandwidthOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithBandwidthLine("opt bandwidth 51200 51200 53470");
+    assertEquals(51200, (int) descriptor.getBandwidthRate());
+    assertEquals(51200, (int) descriptor.getBandwidthBurst());
+    assertEquals(53470, (int) descriptor.getBandwidthObserved());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBandwidthMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithBandwidthLine(null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBandwidthOneValue() throws DescriptorParseException {
+    DescriptorBuilder.createWithBandwidthLine("bandwidth 51200");
+  }
+
+  @Test()
+  public void testBandwidthTwoValues() throws DescriptorParseException {
+    /* This is allowed, because Tor versions 0.0.8 and older only wrote
+     * bandwidth lines with rate and burst values, but no observed
+     * value. */
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithBandwidthLine("bandwidth 51200 51200");
+    assertEquals(51200, (int) descriptor.getBandwidthRate());
+    assertEquals(51200, (int) descriptor.getBandwidthBurst());
+    assertEquals(-1, (int) descriptor.getBandwidthObserved());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBandwidthFourValues() throws DescriptorParseException {
+    DescriptorBuilder.createWithBandwidthLine("bandwidth 51200 51200 "
+        + "53470 53470");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testBandwidthMinusOneTwoThree()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithBandwidthLine("bandwidth -1 -2 -3");
+  }
+
+  @Test()
+  public void testExtraInfoDigestNoOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExtraInfoDigestLine("extra-info-digest "
+        + "1469D1550738A25B1E7B47CDDBCD7B2899F51B74");
+    assertEquals("1469D1550738A25B1E7B47CDDBCD7B2899F51B74",
+        descriptor.getExtraInfoDigest());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExtraInfoDigestNoSpace()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoDigestLine("opt "
+        + "extra-info-digest");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExtraInfoDigestTooShort()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoDigestLine("opt "
+        + "extra-info-digest 1469D1550738A25B1E7B47CDDBCD7B2899F5");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExtraInfoDigestTooLong()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithExtraInfoDigestLine("opt "
+        + "extra-info-digest "
+        + "1469D1550738A25B1E7B47CDDBCD7B2899F51B741469");
+  }
+
+  @Test()
+  public void testExtraInfoDigestMissing()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExtraInfoDigestLine(null);
+    assertNull(descriptor.getExtraInfoDigest());
+  }
+
+  @Test()
+  public void testExtraInfoDigestAdditionalDigest()
+      throws DescriptorParseException {
+    String extraInfoDigest = "0879DB7B765218D7B3AE7557669D20307BB21CAA";
+    String additionalExtraInfoDigest =
+        "V609l+N6ActBveebfNbH5lQ6wHDNstDkFgyqEhBHwtA";
+    String extraInfoDigestLine = String.format("extra-info-digest %s %s",
+        extraInfoDigest, additionalExtraInfoDigest);
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExtraInfoDigestLine(extraInfoDigestLine);
+    assertEquals(extraInfoDigest, descriptor.getExtraInfoDigest());
+  }
+
+  @Test()
+  public void testOnionKeyOpt() throws DescriptorParseException {
+    DescriptorBuilder.createWithOnionKeyLines("opt onion-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ1U4V9SeiKooSo5Bp"
+        + "PL\no3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3olIynCI4QryfCE"
+        + "uC3cTF\n9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKFfacOkpAg"
+        + "MBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----");
+  }
+
+  @Test()
+  public void testSigningKeyOpt() throws DescriptorParseException {
+    DescriptorBuilder.createWithSigningKeyLines("opt signing-key\n"
+        + "-----BEGIN RSA PUBLIC KEY-----\n"
+        + "MIGJAoGBALMm3r3QDh482Ewe6Ub9wvRIfmEkoNX6q5cEAtQRNHSDcNx41gjELb"
+        + "cl\nEniVMParBYACKfOxkS+mTTnIRDKVNEJTsDOwryNrc4X9JnPc/nn6ymYPiN"
+        + "DhUROG\n8URDIhQoixcUeyyrVB8sxliSstKimulGnB7xpjYOlO8JKaHLNL4TAg"
+        + "MBAAE=\n"
+        + "-----END RSA PUBLIC KEY-----");
+  }
+
+  @Test()
+  public void testHiddenServiceDirMissing()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithHiddenServiceDirLine(null);
+    assertNull(descriptor.getHiddenServiceDirVersions());
+  }
+
+  @Test()
+  public void testHiddenServiceDirNoOpt()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithHiddenServiceDirLine("hidden-service-dir");
+    assertEquals(Arrays.asList(new Integer[] {2}),
+        descriptor.getHiddenServiceDirVersions());
+  }
+
+  @Test()
+  public void testHiddenServiceDirVersions2And3()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithHiddenServiceDirLine("hidden-service-dir 2 3");
+    assertEquals(Arrays.asList(new Integer[] {2, 3}),
+        descriptor.getHiddenServiceDirVersions());
+  }
+
+  @Test()
+  public void testContactMissing() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithContactLine(null);
+    assertNull(descriptor.getContact());
+  }
+
+  @Test()
+  public void testContactOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithContactLine("opt contact Random Person");
+    assertEquals("Random Person", descriptor.getContact());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testContactDuplicate() throws DescriptorParseException {
+    DescriptorBuilder.createWithContactLine("contact Random "
+        + "Person\ncontact Random Person");
+  }
+
+  @Test()
+  public void testContactNoSpace() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithContactLine("contact");
+    assertEquals("", descriptor.getContact());
+  }
+
+  @Test()
+  public void testContactCarriageReturn()
+      throws DescriptorParseException {
+    String contactString = "Random "
+        + "Person -----BEGIN PGP PUBLIC KEY BLOCK-----\r"
+        + "Version: GnuPG v1 dot 4 dot 7 (Darwin)\r\r"
+        + "mQGiBEbb0rcRBADqBiUXsmtpJifh74irNnkHbhKMj8O4TqenaZYhdjLWouZsZd"
+        + "07\rmTQoP40G4zqOrVEOOcXpdSiRnHWJYfgTnkibNZrOZEZLn3H1ywpovEgESm"
+        + "oGEdAX\roid3XuIYRpRnqoafbFg9sg+OofX/mGrO+5ACfagQ9rlfx2oxCWijYw"
+        + "pYFRk3NhCY=\r=Xaw3\r-----END PGP PUBLIC KEY BLOCK-----";
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithContactLine("contact " + contactString);
+    assertEquals(contactString, descriptor.getContact());
+  }
+
+  @Test()
+  public void testExitPolicyRejectAllAcceptAll()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExitPolicyLines("reject *:*\naccept *:*");
+    assertEquals(Arrays.asList(new String[] {"reject *:*", "accept *:*"}),
+        descriptor.getExitPolicyLines());
+  }
+
+  @Test()
+  public void testExitPolicyOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExitPolicyLines("opt reject *:*");
+    assertEquals(Arrays.asList(new String[] {"reject *:*"}),
+        descriptor.getExitPolicyLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitPolicyNoPort() throws DescriptorParseException {
+    DescriptorBuilder.createWithExitPolicyLines("reject *");
+  }
+
+  @Test()
+  public void testExitPolicyAccept80RejectAll()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExitPolicyLines("accept *:80\nreject *:*");
+    assertEquals(Arrays.asList(new String[] {"accept *:80",
+        "reject *:*"}), descriptor.getExitPolicyLines());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitPolicyReject321() throws DescriptorParseException {
+    DescriptorBuilder.createWithExitPolicyLines("reject "
+        + "123.123.123.321:80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitPolicyRejectPort66666()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithExitPolicyLines("reject *:66666");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitPolicyProjectAll() throws DescriptorParseException {
+    DescriptorBuilder.createWithExitPolicyLines("project *:*");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testExitPolicyMissing() throws DescriptorParseException {
+    DescriptorBuilder.createWithExitPolicyLines(null);
+  }
+
+  @Test()
+  public void testExitPolicyMaskTypes() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithExitPolicyLines("reject 192.168.0.0/16:*\n"
+        + "reject 94.134.192.243/255.255.255.0:*");
+    assertEquals(Arrays.asList(new String[] { "reject 192.168.0.0/16:*",
+        "reject 94.134.192.243/255.255.255.0:*"}),
+        descriptor.getExitPolicyLines());
+  }
+
+  @Test()
+  public void testRouterSignatureOpt()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterSignatureLines("opt "
+        + "router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "crypto lines are ignored anyway\n"
+        + "-----END SIGNATURE-----");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testRouterSignatureNotLastLine()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithRouterSignatureLines("router-signature\n"
+        + "-----BEGIN SIGNATURE-----\n"
+        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
+        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
+        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
+        + "-----END SIGNATURE-----\ncontact me");
+  }
+
+  @Test()
+  public void testHibernatingOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithHibernatingLine("opt hibernating 1");
+    assertTrue(descriptor.isHibernating());
+  }
+
+  @Test()
+  public void testHibernatingFalse() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithHibernatingLine("hibernating 0");
+    assertFalse(descriptor.isHibernating());
+  }
+
+  @Test()
+  public void testHibernatingTrue() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithHibernatingLine("hibernating 1");
+    assertTrue(descriptor.isHibernating());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testHibernatingYep() throws DescriptorParseException {
+    DescriptorBuilder.createWithHibernatingLine("hibernating yep");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testHibernatingNoSpace() throws DescriptorParseException {
+    DescriptorBuilder.createWithHibernatingLine("hibernating");
+  }
+
+  @Test()
+  public void testFamilyOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithFamilyLine("opt family saberrider2008");
+    assertEquals(Arrays.asList(new String[] {"saberrider2008"}),
+        descriptor.getFamilyEntries());
+  }
+
+  @Test()
+  public void testFamilyFingerprint() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithFamilyLine("family "
+        + "$D8733048FC8EC9102466AD8F3098622BF1BF71FD");
+    assertEquals(Arrays.asList(new String[] {
+        "$D8733048FC8EC9102466AD8F3098622BF1BF71FD"}),
+        descriptor.getFamilyEntries());
+  }
+
+  @Test()
+  public void testFamilyNickname() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithFamilyLine("family saberrider2008");
+    assertEquals(Arrays.asList(new String[] {"saberrider2008"}),
+        descriptor.getFamilyEntries());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFamilyDuplicate() throws DescriptorParseException {
+    DescriptorBuilder.createWithFamilyLine("family "
+        + "saberrider2008\nfamily saberrider2008");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFamilyNicknamePrefix() throws DescriptorParseException {
+    DescriptorBuilder.createWithFamilyLine("family $saberrider2008");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testFamilyFingerprintNoPrefix()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithFamilyLine("family "
+        + "D8733048FC8EC9102466AD8F3098622BF1BF71FD");
+  }
+
+  @Test()
+  public void testFamilyFingerprintNicknameNamed()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithFamilyLine("family "
+        + "$D8733048FC8EC9102466AD8F3098622BF1BF71FD=saberrider2008");
+    assertEquals(Arrays.asList(new String[]
+        { "$D8733048FC8EC9102466AD8F3098622BF1BF71FD=saberrider2008" }),
+        descriptor.getFamilyEntries());
+  }
+
+  @Test()
+  public void testFamilyFingerprintNicknameUnnamed()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithFamilyLine("family "
+        + "$D8733048FC8EC9102466AD8F3098622BF1BF71FD~saberrider2008");
+    assertEquals(Arrays.asList(new String[]
+        { "$D8733048FC8EC9102466AD8F3098622BF1BF71FD~saberrider2008" }),
+        descriptor.getFamilyEntries());
+  }
+
+  @Test()
+  public void testWriteHistory() throws DescriptorParseException {
+    String writeHistoryLine = "write-history 2012-01-01 03:51:44 (900 s) "
+        + "4345856,261120,7591936,1748992";
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithWriteHistoryLine(writeHistoryLine);
+    assertNotNull(descriptor.getWriteHistory());
+    BandwidthHistory parsedWriteHistory = descriptor.getWriteHistory();
+    assertEquals(writeHistoryLine, parsedWriteHistory.getLine());
+    assertEquals(1325389904000L, (long) parsedWriteHistory.
+        getHistoryEndMillis());
+    assertEquals(900L, (long) parsedWriteHistory.getIntervalLength());
+    SortedMap<Long, Long> bandwidthValues = parsedWriteHistory.
+        getBandwidthValues();
+    assertEquals(4345856L, (long) bandwidthValues.remove(1325387204000L));
+    assertEquals(261120L, (long) bandwidthValues.remove(1325388104000L));
+    assertEquals(7591936L, (long) bandwidthValues.remove(1325389004000L));
+    assertEquals(1748992L, (long) bandwidthValues.remove(1325389904000L));
+    assertTrue(bandwidthValues.isEmpty());
+  }
+
+  @Test()
+  public void testWriteHistoryOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithWriteHistoryLine("opt write-history 2012-01-01 "
+        + "03:51:44 (900 s) 4345856,261120,7591936,1748992");
+    assertNotNull(descriptor.getWriteHistory());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistory3012() throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "3012-01-01 03:51:44 (900 s) 4345856,261120,7591936,1748992");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryNoSeconds()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-01-01 03:51 (900 s) 4345856,261120,7591936,1748992");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryNoParathenses()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-01-01 03:51:44 900 s 4345856,261120,7591936,1748992");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryNoSpaceSeconds()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-01-01 03:51:44 (900s) 4345856,261120,7591936,1748992");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryTrailingComma()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-01-01 03:51:44 (900 s) 4345856,261120,7591936,");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryOneTwoThree()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-01-01 03:51:44 (900 s) one,two,three");
+  }
+
+  @Test()
+  public void testWriteHistoryNoValuesSpace()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithWriteHistoryLine("write-history 2012-01-01 03:51:44 "
+        + "(900 s) ");
+    assertEquals(900, (long) descriptor.getWriteHistory().
+        getIntervalLength());
+    assertTrue(descriptor.getWriteHistory().getBandwidthValues().
+        isEmpty());
+  }
+
+  @Test()
+  public void testWriteHistoryNoValuesNoSpace()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithWriteHistoryLine("write-history 2012-01-01 03:51:44 "
+        + "(900 s)");
+    assertEquals(900, (long) descriptor.getWriteHistory().
+        getIntervalLength());
+    assertTrue(descriptor.getWriteHistory().getBandwidthValues().
+        isEmpty());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryNoS() throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine(
+        "write-history 2012-01-01 03:51:44 (900 ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testWriteHistoryTrailingNumber()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithWriteHistoryLine("write-history "
+        + "2012-01-01 03:51:44 (900 s) 4345856 1");
+  }
+
+  @Test()
+  public void testWriteHistory1800Seconds()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithWriteHistoryLine("write-history 2012-01-01 03:51:44 "
+        + "(1800 s) 4345856");
+    assertEquals(1800L, (long) descriptor.getWriteHistory().
+        getIntervalLength());
+  }
+
+  @Test()
+  public void testReadHistory() throws DescriptorParseException {
+    String readHistoryLine = "read-history 2012-01-01 03:51:44 (900 s) "
+        + "4268032,139264,7797760,1415168";
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithReadHistoryLine(readHistoryLine);
+    assertNotNull(descriptor.getReadHistory());
+    BandwidthHistory parsedReadHistory = descriptor.getReadHistory();
+    assertEquals(readHistoryLine, parsedReadHistory.getLine());
+    assertEquals(1325389904000L, (long) parsedReadHistory.
+        getHistoryEndMillis());
+    assertEquals(900L, (long) parsedReadHistory.getIntervalLength());
+    SortedMap<Long, Long> bandwidthValues = parsedReadHistory.
+        getBandwidthValues();
+    assertEquals(4268032L, (long) bandwidthValues.remove(1325387204000L));
+    assertEquals(139264L, (long) bandwidthValues.remove(1325388104000L));
+    assertEquals(7797760L, (long) bandwidthValues.remove(1325389004000L));
+    assertEquals(1415168L, (long) bandwidthValues.remove(1325389904000L));
+    assertTrue(bandwidthValues.isEmpty());
+  }
+
+  @Test()
+  public void testReadHistoryTwoSpaces() throws DescriptorParseException {
+    /* There are some server descriptors from older Tor versions that
+     * contain "opt read-history  " lines. */
+    String readHistoryLine = "opt read-history  2012-01-01 03:51:44 "
+        + "(900 s) 4268032,139264,7797760,1415168";
+    DescriptorBuilder.createWithReadHistoryLine(readHistoryLine);
+  }
+
+  @Test()
+  public void testEventdnsOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithEventdnsLine("opt eventdns 1");
+    assertTrue(descriptor.getUsesEnhancedDnsLogic());
+  }
+
+  @Test()
+  public void testEventdns1() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithEventdnsLine("eventdns 1");
+    assertTrue(descriptor.getUsesEnhancedDnsLogic());
+  }
+
+  @Test()
+  public void testEventdns0() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithEventdnsLine("eventdns 0");
+    assertFalse(descriptor.getUsesEnhancedDnsLogic());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEventdnsTrue() throws DescriptorParseException {
+    DescriptorBuilder.createWithEventdnsLine("eventdns true");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEventdnsNo() throws DescriptorParseException {
+    DescriptorBuilder.createWithEventdnsLine("eventdns no");
+  }
+
+  @Test()
+  public void testCachesExtraInfoOpt() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithCachesExtraInfoLine("opt caches-extra-info");
+    assertTrue(descriptor.getCachesExtraInfo());
+  }
+
+  @Test()
+  public void testCachesExtraInfoNoSpace()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithCachesExtraInfoLine("caches-extra-info");
+    assertTrue(descriptor.getCachesExtraInfo());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testCachesExtraInfoTrue() throws DescriptorParseException {
+    DescriptorBuilder.createWithCachesExtraInfoLine("caches-extra-info "
+        + "true");
+  }
+
+  @Test()
+  public void testAllowSingleHopExitsOpt()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithAllowSingleHopExitsLine("opt allow-single-hop-exits");
+    assertTrue(descriptor.getAllowSingleHopExits());
+  }
+
+  @Test()
+  public void testAllowSingleHopExitsNoSpace()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithAllowSingleHopExitsLine("allow-single-hop-exits");
+    assertTrue(descriptor.getAllowSingleHopExits());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAllowSingleHopExitsTrue()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithAllowSingleHopExitsLine(
+        "allow-single-hop-exits true");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testAllowSingleHopExitsNonAsciiKeyword()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithNonAsciiLineBytes(new byte[] {
+        0x14, (byte) 0xfe, 0x18,                  // non-ascii chars
+        0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d,       // "allow-"
+        0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x2d, // "single-"
+        0x68, 0x6f, 0x70, 0x2d,                   // "hop-"
+        0x65, 0x78, 0x69, 0x74, 0x73 },           // "exits" (no newline)
+        false);
+  }
+
+  @Test()
+  public void testIpv6PolicyLine() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithIpv6PolicyLine("ipv6-policy accept 80,1194,1220,1293");
+    assertEquals("accept", descriptor.getIpv6DefaultPolicy());
+    assertEquals("80,1194,1220,1293", descriptor.getIpv6PortList());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIpv6PolicyLineNoPolicy()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIpv6PolicyLineNoPorts()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy accept");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIpv6PolicyLineNoPolicyNoPorts()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testIpv6PolicyLineProject()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy project 80");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTwoIpv6PolicyLines() throws DescriptorParseException {
+    DescriptorBuilder.createWithIpv6PolicyLine(
+        "ipv6-policy accept 80,1194,1220,1293\n"
+        + "ipv6-policy accept 80,1194,1220,1293");
+  }
+
+  @Test()
+  public void testNtorOnionKeyLine() throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithNtorOnionKeyLine("ntor-onion-key "
+        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY=");
+    assertEquals("Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY",
+        descriptor.getNtorOnionKey());
+  }
+
+  @Test()
+  public void testNtorOnionKeyLineNoPadding()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithNtorOnionKeyLine("ntor-onion-key "
+        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY");
+    assertEquals("Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY",
+        descriptor.getNtorOnionKey());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNtorOnionKeyLineNoKey()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithNtorOnionKeyLine("ntor-onion-key ");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNtorOnionKeyLineTwoKeys()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithNtorOnionKeyLine("ntor-onion-key "
+        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY "
+        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTwoNtorOnionKeyLines() throws DescriptorParseException {
+    DescriptorBuilder.createWithNtorOnionKeyLine("ntor-onion-key "
+        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY\nntor-onion-key "
+        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY\n");
+  }
+
+  @Test()
+  public void testTunnelledDirServerTrue()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder
+        .createWithTunnelledDirServerLine("tunnelled-dir-server");
+    assertTrue(descriptor.getTunnelledDirServer());
+  }
+
+  @Test()
+  public void testTunnelledDirServerFalse()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor = DescriptorBuilder
+        .createWithTunnelledDirServerLine(null);
+    assertFalse(descriptor.getTunnelledDirServer());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTunnelledDirServerTypo()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithTunnelledDirServerLine(
+        "tunneled-dir-server");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTunnelledDirServerTwice()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithTunnelledDirServerLine(
+        "tunnelled-dir-server\ntunnelled-dir-server");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testTunnelledDirServerArgs()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithTunnelledDirServerLine(
+        "tunnelled-dir-server 1");
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testUnrecognizedLineFail()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    DescriptorBuilder.createWithUnrecognizedLine(unrecognizedLine, true);
+  }
+
+  @Test()
+  public void testUnrecognizedLineIgnore()
+      throws DescriptorParseException {
+    String unrecognizedLine = "unrecognized-line 1";
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithUnrecognizedLine(unrecognizedLine, false);
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add(unrecognizedLine);
+    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
+  }
+
+  @Test()
+  public void testSomeOtherKey() throws DescriptorParseException {
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add("some-other-key");
+    unrecognizedLines.add("-----BEGIN RSA PUBLIC KEY-----");
+    unrecognizedLines.add("MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ"
+        + "1U4V9SeiKooSo5BpPL");
+    unrecognizedLines.add("o3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3ol"
+        + "IynCI4QryfCEuC3cTF");
+    unrecognizedLines.add("9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKF"
+        + "facOkpAgMBAAE=");
+    unrecognizedLines.add("-----END RSA PUBLIC KEY-----");
+    StringBuilder sb = new StringBuilder();
+    for (String line : unrecognizedLines) {
+      sb.append("\n").append(line);
+    }
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithUnrecognizedLine(sb.toString().substring(1), false);
+    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
+  }
+
+  @Test()
+  public void testUnrecognizedCryptoBlockNoKeyword()
+      throws DescriptorParseException {
+    List<String> unrecognizedLines = new ArrayList<>();
+    unrecognizedLines.add("-----BEGIN RSA PUBLIC KEY-----");
+    unrecognizedLines.add("MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ"
+        + "1U4V9SeiKooSo5BpPL");
+    unrecognizedLines.add("o3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3ol"
+        + "IynCI4QryfCEuC3cTF");
+    unrecognizedLines.add("9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKF"
+        + "facOkpAgMBAAE=");
+    unrecognizedLines.add("-----END RSA PUBLIC KEY-----");
+    StringBuilder sb = new StringBuilder();
+    for (String line : unrecognizedLines) {
+      sb.append("\n").append(line);
+    }
+    ServerDescriptor descriptor = DescriptorBuilder.
+        createWithUnrecognizedLine(sb.toString().substring(1), false);
+    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
+  }
+
+  private static final String IDENTITY_ED25519_LINES =
+      "identity-ed25519\n"
+      + "-----BEGIN ED25519 CERT-----\n"
+      + "AQQABiX1AVGv5BuzJroQXbOh6vv1nbwc5rh2S13PyRFuLhTiifK4AQAgBACBCMwr"
+      + "\n4qgIlFDIzoC9ieJOtSkwrK+yXJPKlP8ojvgkx8cGKvhokOwA1eYDombzfwHcJ1"
+      + "EV\nbhEn/6g8i7wzO3LoqefIUrSAeEExOAOmm5mNmUIzL8EtnT6JHCr/sqUTUgA="
+      + "\n"
+      + "-----END ED25519 CERT-----";
+
+  private static final String MASTER_KEY_ED25519_LINE =
+      "master-key-ed25519 gQjMK+KoCJRQyM6AvYniTrUpMKyvslyTypT/KI74JMc";
+
+  private static final String ROUTER_SIG_ED25519_LINE =
+      "router-sig-ed25519 y7WF9T2GFwkSDPZEhB55HgquIFOl5uXUFMYJPq3CXXUTKeJ"
+      + "kSrtaZUB5s34fWdHQNtl84mH4dVaFMunHnwgYAw";
+
+  @Test()
+  public void testEd25519() throws DescriptorParseException {
+    ServerDescriptor descriptor =
+        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
+    assertEquals(IDENTITY_ED25519_LINES.substring(
+        IDENTITY_ED25519_LINES.indexOf("\n") + 1),
+        descriptor.getIdentityEd25519());
+    assertEquals(MASTER_KEY_ED25519_LINE.substring(
+        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
+        descriptor.getMasterKeyEd25519());
+    assertEquals(ROUTER_SIG_ED25519_LINE.substring(
+        ROUTER_SIG_ED25519_LINE.indexOf(" ") + 1),
+        descriptor.getRouterSignatureEd25519());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519IdentityMasterKeyMismatch()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        "master-key-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+        ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testEd25519IdentityMissing()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(null,
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519IdentityDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES + "\n"
+        + IDENTITY_ED25519_LINES, MASTER_KEY_ED25519_LINE,
+        ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519IdentityEmptyCrypto()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines("identity-ed25519\n"
+        + "-----BEGIN ED25519 CERT-----\n-----END ED25519 CERT-----",
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testEd25519MasterKeyMissing()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor =
+        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        null, ROUTER_SIG_ED25519_LINE);
+    assertEquals(MASTER_KEY_ED25519_LINE.substring(
+        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
+        descriptor.getMasterKeyEd25519());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519MasterKeyDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE + "\n" + MASTER_KEY_ED25519_LINE,
+        ROUTER_SIG_ED25519_LINE);
+  }
+
+  @Test()
+  public void testEd25519RouterSigMissing()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE, null);
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testEd25519RouterSigDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
+        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE + "\n"
+        + ROUTER_SIG_ED25519_LINE);
+  }
+
+  private static final String ONION_KEY_CROSSCERT_LINES =
+      "onion-key-crosscert\n"
+      + "-----BEGIN CROSSCERT-----\n"
+      + "gVWpiNgG2FekW1uonr4KKoqykjr4bqUBKGZfu6s9rvsV1TThnquZNP6ZhX2IPdQA"
+      + "\nlfKtzFggGu/4BiJ5oTSDj2sK2DMjY3rjrMQZ3I/wJ25yhc9gxjqYqUYO9MmJwA"
+      + "Lp\nfYkqp/t4WchJpyva/4hK8vITsI6eT2BfY/DWMy/suIE=\n"
+      + "-----END CROSSCERT-----";
+
+  private static final String NTOR_ONION_KEY_CROSSCERT_LINES =
+      "ntor-onion-key-crosscert 1\n"
+      + "-----BEGIN ED25519 CERT-----\n"
+      + "AQoABiUeAdauu1MxYGMmGLTCPaoes0RvW7udeLc1t8LZ4P3CDo5bAN4nrRfbCfOt"
+      + "\nz2Nwqn8tER1a+Ry6Vs+ilMZA55Rag4+f6Zdb1fmHWknCxbQlLHpqHACMtemPda"
+      + "Ka\nErPtMuiEqAc=\n"
+      + "-----END ED25519 CERT-----";
+
+  @Test()
+  public void testOnionKeyCrosscert() throws DescriptorParseException {
+    ServerDescriptor descriptor =
+        DescriptorBuilder.createWithOnionKeyCrosscertLines(
+        ONION_KEY_CROSSCERT_LINES);
+    assertEquals(ONION_KEY_CROSSCERT_LINES.substring(
+        ONION_KEY_CROSSCERT_LINES.indexOf("\n") + 1),
+        descriptor.getOnionKeyCrosscert());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testOnionKeyCrosscertDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithOnionKeyCrosscertLines(
+    ONION_KEY_CROSSCERT_LINES + "\n" + ONION_KEY_CROSSCERT_LINES);
+  }
+
+  @Test()
+  public void testNtorOnionKeyCrosscert()
+      throws DescriptorParseException {
+    ServerDescriptor descriptor =
+        DescriptorBuilder.createWithNtorOnionKeyCrosscertLines(
+        NTOR_ONION_KEY_CROSSCERT_LINES);
+    assertEquals(NTOR_ONION_KEY_CROSSCERT_LINES.substring(
+        NTOR_ONION_KEY_CROSSCERT_LINES.indexOf("\n") + 1),
+        descriptor.getNtorOnionKeyCrosscert());
+    assertEquals(1, descriptor.getNtorOnionKeyCrosscertSign());
+  }
+
+  @Test(expected = DescriptorParseException.class)
+  public void testNtorOnionKeyCrosscertDuplicate()
+      throws DescriptorParseException {
+    DescriptorBuilder.createWithOnionKeyCrosscertLines(
+        NTOR_ONION_KEY_CROSSCERT_LINES + "\n"
+        + NTOR_ONION_KEY_CROSSCERT_LINES);
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/TorperfResultImplTest.java b/src/test/java/org/torproject/descriptor/impl/TorperfResultImplTest.java
new file mode 100644
index 0000000..b5cde0a
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/TorperfResultImplTest.java
@@ -0,0 +1,97 @@
+/* Copyright 2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.util.List;
+
+import org.junit.Test;
+import org.torproject.descriptor.Descriptor;
+
+public class TorperfResultImplTest {
+
+  @Test()
+  public void testAnnotatedInput() throws Exception{
+    TorperfResultImpl result = (TorperfResultImpl)
+        (TorperfResultImpl.parseTorperfResults((torperfAnnotation + input)
+        .getBytes("US-ASCII"), false).get(0));
+    assertEquals("Expected one annotation.", 1,
+        result.getAnnotations().size());
+    assertEquals(torperfAnnotation.substring(0, 17),
+        result.getAnnotations().get(0));
+    int count = 0;
+    for (Long l: result.getDataPercentiles().values()) {
+      assertNotNull(l);
+      assertEquals(l.longValue(), deciles[count++]);
+    }
+  }
+
+  @Test()
+  public void testPartiallyAnnotatedInput() throws Exception{
+    byte[] asciiBytes = (torperfAnnotation
+        + input + input + input).getBytes("US-ASCII");
+    List<Descriptor> result = TorperfResultImpl.parseTorperfResults(
+        asciiBytes, false);
+    assertEquals("Expected one annotation.", 1,
+        ((TorperfResultImpl)(result.get(0))).getAnnotations().size());
+    assertEquals(3, result.size());
+    assertEquals("Expected zero annotations.", 0,
+        ((TorperfResultImpl)(result.get(1))).getAnnotations().size());
+    assertEquals("Expected zero annotations.", 0,
+        ((TorperfResultImpl)(result.get(2))).getAnnotations().size());
+  }
+
+  @Test()
+  public void testAllAnnotatedInput() throws Exception {
+    byte[] asciiBytes = (torperfAnnotation + input
+        + torperfAnnotation + input
+        + torperfAnnotation + input).getBytes("US-ASCII");
+    List<Descriptor> result = TorperfResultImpl.parseTorperfResults(
+        asciiBytes, false);
+    assertEquals("Expected one annotation.", 1,
+        ((TorperfResultImpl)(result.get(0))).getAnnotations().size());
+    assertEquals(3, result.size());
+    assertEquals("Expected one annotation.", 1,
+        ((TorperfResultImpl)(result.get(1))).getAnnotations().size());
+    assertEquals("Expected one annotation.", 1,
+        ((TorperfResultImpl)(result.get(2))).getAnnotations().size());
+  }
+
+  private static long[] deciles = new long[] {
+      1441065602980L, 1441065603030L, 1441065603090L, 1441065603120L,
+      1441065603230L, 1441065603250L, 1441065603310L, 1441065603370L,
+      1441065603370L };
+
+  private static final String torperfAnnotation = "@type torperf 1.0\n";
+
+  private static final String input =
+      "BUILDTIMES=0.872834920883,1.09103679657,1.49180984497 "
+      + "CIRC_ID=1228 CONNECT=1441065601.86 DATACOMPLETE=1441065603.39 "
+      + "DATAPERC10=1441065602.98 DATAPERC20=1441065603.03 "
+      + "DATAPERC30=1441065603.09 DATAPERC40=1441065603.12 "
+      + "DATAPERC50=1441065603.23 DATAPERC60=1441065603.25 "
+      + "DATAPERC70=1441065603.31 DATAPERC80=1441065603.37 "
+      + "DATAPERC90=1441065603.37 DATAREQUEST=1441065602.38 "
+      + "DATARESPONSE=1441065602.84 DIDTIMEOUT=0 FILESIZE=51200 "
+      + "LAUNCH=1441065361.30 NEGOTIATE=1441065601.86 "
+      + "PATH=$C4C9C332D25B3546BEF4E1250CF410E97EF996E6,"
+      + "$C43FA6474A9F071E9120DF63ED6EB8FDBA105234,"
+      + "$7C0AA4E3B73E407E9F5FEB1912F8BE26D8AA124D QUANTILE=0.800000 "
+      + "READBYTES=51416 REQUEST=1441065601.86 RESPONSE=1441065602.38 "
+      + "SOCKET=1441065601.86 SOURCE=moria START=1441065601.86 "
+      + "TIMEOUT=1500 USED_AT=1441065603.40 USED_BY=2475 WRITEBYTES=75\n";
+
+  @Test()
+  public void testDatapercNonNumeric() throws Exception {
+    List<Descriptor> result = TorperfResultImpl.parseTorperfResults(
+        ("DATAPERMILLE=2.0 " + input).getBytes(), false);
+    assertEquals(1, result.size());
+    TorperfResultImpl torperfResult = (TorperfResultImpl) result.get(0);
+    assertEquals(1, torperfResult.getUnrecognizedKeys().size());
+    assertEquals("DATAPERMILLE",
+        torperfResult.getUnrecognizedKeys().firstKey());
+  }
+}
+
diff --git a/test/org/torproject/descriptor/benchmark/MeasurePerformance.java b/test/org/torproject/descriptor/benchmark/MeasurePerformance.java
deleted file mode 100644
index a52020a..0000000
--- a/test/org/torproject/descriptor/benchmark/MeasurePerformance.java
+++ /dev/null
@@ -1,278 +0,0 @@
-/* Copyright 2016 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.benchmark;
-
-import org.torproject.descriptor.Descriptor;
-import org.torproject.descriptor.DescriptorFile;
-import org.torproject.descriptor.DescriptorReader;
-import org.torproject.descriptor.DescriptorSourceFactory;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-import org.torproject.descriptor.Microdescriptor;
-import org.torproject.descriptor.NetworkStatusEntry;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-import org.torproject.descriptor.ServerDescriptor;
-
-import java.io.File;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Set;
-import java.util.SortedMap;
-
-public class MeasurePerformance {
-
-  /* Check if all necessary files are available and then measure
-   * performance of some more or less common use cases. */
-  public static void main(String[] args) {
-    if (!filesAvailable()) {
-      return;
-    }
-    measureAverageAdvertisedBandwidth(new File(resDir, resPaths[0]));
-    pause();
-    measureAverageAdvertisedBandwidth(new File(resDir, resPaths[1]));
-    pause();
-    measureAverageAdvertisedBandwidth(new File(resDir, resPaths[2]));
-    pause();
-    measureCountriesV3Requests(new File(resDir, resPaths[3]));
-    pause();
-    measureCountriesV3Requests(new File(resDir, resPaths[4]));
-    pause();
-    measureAverageRelaysExit(new File(resDir, resPaths[5]));
-    pause();
-    measureAverageRelaysExit(new File(resDir, resPaths[6]));
-    pause();
-    measureAverageRelaysExit(new File(resDir, resPaths[7]));
-    measureFractionRelaysExit80Microdescriptors(
-        new File(resDir, resPaths[8]));
-    measureFractionRelaysExit80Microdescriptors(
-        new File(resDir, resPaths[9]));
-  }
-
-  private static File resDir = new File("res");
-  private static String[] resPaths = new String[] {
-    "archive/relay-descriptors/server-descriptors/"
-        + "server-descriptors-2015-11.tar.xz",
-    "archive/relay-descriptors/server-descriptors/"
-        + "server-descriptors-2015-11.tar",
-    "archive/relay-descriptors/server-descriptors/"
-        + "server-descriptors-2015-11",
-    "archive/relay-descriptors/extra-infos/extra-infos-2015-11.tar.xz",
-    "archive/relay-descriptors/extra-infos/extra-infos-2015-11.tar",
-    "archive/relay-descriptors/consensuses/consensuses-2015-11.tar.xz",
-    "archive/relay-descriptors/consensuses/consensuses-2015-11.tar",
-    "archive/relay-descriptors/consensuses/consensuses-2015-11",
-    "archive/relay-descriptors/microdescs/microdescs-2015-11.tar.xz",
-    "archive/relay-descriptors/microdescs/microdescs-2015-11.tar"
-  };
-
-  private static boolean filesAvailable() {
-    if (!resDir.exists() || !resDir.isDirectory()) {
-      return false;
-    }
-    for (String resPath : resPaths) {
-      if (!(new File(resDir, resPath).exists())) {
-        System.err.println("Missing resource: " + resDir + "/" + resPath);
-        return false;
-      }
-    }
-    return true;
-  }
-
-  private static void pause() {
-    try {
-      Thread.sleep(15L * 1000L);
-    } catch (InterruptedException e) {
-      e.printStackTrace();
-    }
-  }
-
-  private static void measureAverageAdvertisedBandwidth(
-      File tarballFileOrDirectory) {
-    System.out.println("Starting measureAverageAdvertisedBandwidth");
-    long startedMillis = System.currentTimeMillis();
-    long sumAdvertisedBandwidth = 0, countedServerDescriptors = 0;
-    DescriptorReader descriptorReader =
-        DescriptorSourceFactory.createDescriptorReader();
-    descriptorReader.addTarball(tarballFileOrDirectory);
-    descriptorReader.addDirectory(tarballFileOrDirectory);
-    Iterator<DescriptorFile> descriptorFiles =
-        descriptorReader.readDescriptors();
-    while (descriptorFiles.hasNext()) {
-      DescriptorFile descriptorFile = descriptorFiles.next();
-      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
-        if (!(descriptor instanceof ServerDescriptor)) {
-          continue;
-        }
-        ServerDescriptor serverDescriptor = (ServerDescriptor) descriptor;
-        sumAdvertisedBandwidth += (long) Math.min(Math.min(
-            serverDescriptor.getBandwidthRate(),
-            serverDescriptor.getBandwidthBurst()),
-            serverDescriptor.getBandwidthObserved());
-        countedServerDescriptors++;
-      }
-    }
-    long endedMillis = System.currentTimeMillis();
-    System.out.println("Ending measureAverageAdvertisedBandwidth");
-    System.out.printf("Total time: %d millis%n",
-        endedMillis - startedMillis);
-    System.out.printf("Processed server descriptors: %d%n",
-        countedServerDescriptors);
-    System.out.printf("Average advertised bandwidth: %d%n",
-        sumAdvertisedBandwidth / countedServerDescriptors);
-    System.out.printf("Time per server descriptor: %.6f millis%n",
-        ((double) (endedMillis - startedMillis))
-        / ((double) countedServerDescriptors));
-  }
-
-  private static void measureCountriesV3Requests(File tarballFile) {
-    System.out.println("Starting measureCountriesV3Requests");
-    long startedMillis = System.currentTimeMillis();
-    Set<String> countries = new HashSet<>();
-    long countedExtraInfoDescriptors = 0;
-    DescriptorReader descriptorReader =
-        DescriptorSourceFactory.createDescriptorReader();
-    descriptorReader.addTarball(tarballFile);
-    Iterator<DescriptorFile> descriptorFiles =
-        descriptorReader.readDescriptors();
-    while (descriptorFiles.hasNext()) {
-      DescriptorFile descriptorFile = descriptorFiles.next();
-      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
-        if (!(descriptor instanceof ExtraInfoDescriptor)) {
-          continue;
-        }
-        ExtraInfoDescriptor extraInfoDescriptor =
-            (ExtraInfoDescriptor) descriptor;
-        SortedMap<String, Integer> dirreqV3Reqs =
-            extraInfoDescriptor.getDirreqV3Reqs();
-        if (dirreqV3Reqs != null) {
-          countries.addAll(dirreqV3Reqs.keySet());
-        }
-        countedExtraInfoDescriptors++;
-      }
-    }
-    long endedMillis = System.currentTimeMillis();
-    System.out.println("Ending measureCountriesV3Requests");
-    System.out.printf("Total time: %d millis%n",
-        endedMillis - startedMillis);
-    System.out.printf("Processed extra-info descriptors: %d%n",
-        countedExtraInfoDescriptors);
-    System.out.printf("Number of countries: %d%n",
-        countries.size());
-    System.out.printf("Time per extra-info descriptor: %.6f millis%n",
-        ((double) (endedMillis - startedMillis))
-        / ((double) countedExtraInfoDescriptors));
-  }
-
-  private static void measureAverageRelaysExit(
-      File tarballFileOrDirectory) {
-    System.out.println("Starting measureAverageRelaysExit");
-    long startedMillis = System.currentTimeMillis();
-    long totalRelaysWithExitFlag = 0L, totalRelays = 0L,
-        countedConsensuses = 0L;
-    DescriptorReader descriptorReader =
-        DescriptorSourceFactory.createDescriptorReader();
-    descriptorReader.addTarball(tarballFileOrDirectory);
-    descriptorReader.addDirectory(tarballFileOrDirectory);
-    Iterator<DescriptorFile> descriptorFiles =
-        descriptorReader.readDescriptors();
-    while (descriptorFiles.hasNext()) {
-      DescriptorFile descriptorFile = descriptorFiles.next();
-      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
-        if (!(descriptor instanceof RelayNetworkStatusConsensus)) {
-          continue;
-        }
-        RelayNetworkStatusConsensus consensus =
-            (RelayNetworkStatusConsensus) descriptor;
-        for (NetworkStatusEntry entry :
-            consensus.getStatusEntries().values()) {
-          if (entry.getFlags().contains("Exit")) {
-            totalRelaysWithExitFlag++;
-          }
-          totalRelays++;
-        }
-        countedConsensuses++;
-      }
-    }
-    long endedMillis = System.currentTimeMillis();
-    System.out.println("Ending measureAverageRelaysExit");
-    System.out.printf("Total time: %d millis%n",
-        endedMillis - startedMillis);
-    System.out.printf("Processed consensuses: %d%n", countedConsensuses);
-    System.out.printf("Total number of status entries: %d%n",
-        totalRelays);
-    System.out.printf("Total number of status entries with Exit flag: "
-        + "%d%n", totalRelaysWithExitFlag);
-    System.out.printf("Average number of relays with Exit Flag: %.2f%n",
-        (double) totalRelaysWithExitFlag / (double) totalRelays);
-    System.out.printf("Time per consensus: %.6f millis%n",
-        ((double) (endedMillis - startedMillis))
-        / ((double) countedConsensuses));
-  }
-
-  private static void measureFractionRelaysExit80Microdescriptors(
-      File tarballFile) {
-    System.out.println("Starting "
-        + "measureFractionRelaysExit80Microdescriptors");
-    long startedMillis = System.currentTimeMillis();
-    long totalRelaysWithExitFlag = 0L, countedMicrodescriptors = 0L;
-    DescriptorReader descriptorReader =
-        DescriptorSourceFactory.createDescriptorReader();
-    descriptorReader.addTarball(tarballFile);
-    Iterator<DescriptorFile> descriptorFiles =
-        descriptorReader.readDescriptors();
-    while (descriptorFiles.hasNext()) {
-      DescriptorFile descriptorFile = descriptorFiles.next();
-      for (Descriptor descriptor : descriptorFile.getDescriptors()) {
-        if (!(descriptor instanceof Microdescriptor)) {
-          continue;
-        }
-        countedMicrodescriptors++;
-        Microdescriptor microdescriptor =
-            (Microdescriptor) descriptor;
-        String defaultPolicy = microdescriptor.getDefaultPolicy();
-        if (defaultPolicy == null) {
-          continue;
-        }
-        boolean accept = "accept".equals(
-            microdescriptor.getDefaultPolicy());
-        for (String ports : microdescriptor.getPortList().split(",")) {
-          if (ports.contains("-")) {
-            String[] parts = ports.split("-");
-            int from = Integer.parseInt(parts[0]);
-            int to = Integer.parseInt(parts[1]);
-            if (from <= 80 && to >= 80) {
-              if (accept) {
-                totalRelaysWithExitFlag++;
-              }
-            } else if (to > 80) {
-              if (!accept) {
-                totalRelaysWithExitFlag++;
-              }
-              break;
-            }
-          } else if ("80".equals(ports)) {
-            if (accept) {
-              totalRelaysWithExitFlag++;
-            }
-            break;
-          }
-        }
-      }
-    }
-    long endedMillis = System.currentTimeMillis();
-    System.out.println("Ending "
-        + "measureFractionRelaysExit80Microdescriptors");
-    System.out.printf("Total time: %d millis%n",
-        endedMillis - startedMillis);
-    System.out.printf("Processed microdescriptors: %d%n",
-        countedMicrodescriptors);
-    System.out.printf("Total number of microdescriptors that exit to 80: "
-        + "%d%n", totalRelaysWithExitFlag);
-    System.out.printf("Average number of relays that exit to 80: %.2f%n",
-        (double) totalRelaysWithExitFlag
-        / (double) countedMicrodescriptors);
-    System.out.printf("Time per microdescriptor: %.6f millis%n",
-        ((double) (endedMillis - startedMillis))
-        / ((double) countedMicrodescriptors));
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/BridgeNetworkStatusTest.java b/test/org/torproject/descriptor/impl/BridgeNetworkStatusTest.java
deleted file mode 100644
index 0847e13..0000000
--- a/test/org/torproject/descriptor/impl/BridgeNetworkStatusTest.java
+++ /dev/null
@@ -1,151 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.junit.Test;
-import org.torproject.descriptor.BridgeNetworkStatus;
-import org.torproject.descriptor.DescriptorParseException;
-
-/* Test parsing of bridge network statuses.  Some of the parsing code is
- * already tested in the consensus/vote-parsing tests. */
-public class BridgeNetworkStatusTest {
-
-  /* Helper class to build a bridge network status based on default data
-   * and modifications requested by test methods. */
-  private static class StatusBuilder {
-    private String fileName = "20151121-173936-"
-        + "4A0CCD2DDC7995083D73F5D667100C8A5831F16D";
-    private static BridgeNetworkStatus
-        createWithFileName(String fileName)
-        throws DescriptorParseException {
-      StatusBuilder sb = new StatusBuilder();
-      sb.fileName = fileName;
-      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
-          true);
-    }
-    private String publishedLine = "published 2015-11-21 17:39:36";
-    private static BridgeNetworkStatus
-        createWithPublishedLine(String line)
-        throws DescriptorParseException {
-      StatusBuilder sb = new StatusBuilder();
-      sb.publishedLine = line;
-      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
-          true);
-    }
-    private String flagThresholdsLine = "flag-thresholds "
-        + "stable-uptime=3105080 stable-mtbf=2450615 fast-speed=55000 "
-        + "guard-wfu=98.000% guard-tk=691200 guard-bw-inc-exits=337000 "
-        + "guard-bw-exc-exits=339000 enough-mtbf=1 "
-        + "ignoring-advertised-bws=0";
-    private static BridgeNetworkStatus
-        createWithFlagThresholdsLine(String line)
-        throws DescriptorParseException {
-      StatusBuilder sb = new StatusBuilder();
-      sb.flagThresholdsLine = line;
-      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
-          true);
-    }
-    private List<String> statusEntries = new ArrayList<>();
-    private String unrecognizedHeaderLine = null;
-    protected static BridgeNetworkStatus
-        createWithUnrecognizedHeaderLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      StatusBuilder sb = new StatusBuilder();
-      sb.unrecognizedHeaderLine = line;
-      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
-          failUnrecognizedDescriptorLines);
-    }
-    private String unrecognizedStatusEntryLine = null;
-    protected static BridgeNetworkStatus
-        createWithUnrecognizedStatusEntryLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      StatusBuilder sb = new StatusBuilder();
-      sb.unrecognizedStatusEntryLine = line;
-      return new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName,
-          failUnrecognizedDescriptorLines);
-    }
-
-    private StatusBuilder() {
-      this.statusEntries.add("r Unnamed ABk0wg4j6BLCdZKleVtmNrfzJGI "
-          + "bh7gVU1Cz6+JG+7j4qGsF4prDi8 2015-11-21 15:46:25 "
-          + "10.153.163.200 443 0\ns Fast Running Stable Valid\n"
-          + "w Bandwidth=264\np reject 1-65535");
-    }
-    private byte[] buildStatus() {
-      StringBuilder sb = new StringBuilder();
-      this.appendHeader(sb);
-      this.appendStatusEntries(sb);
-      return sb.toString().getBytes();
-    }
-    private void appendHeader(StringBuilder sb) {
-      if (this.publishedLine != null) {
-        sb.append(this.publishedLine).append("\n");
-      }
-      if (this.flagThresholdsLine != null) {
-        sb.append(this.flagThresholdsLine).append("\n");
-      }
-      if (this.unrecognizedHeaderLine != null) {
-        sb.append(this.unrecognizedHeaderLine).append("\n");
-      }
-    }
-    private void appendStatusEntries(StringBuilder sb) {
-      for (String statusEntry : this.statusEntries) {
-        sb.append(statusEntry).append("\n");
-      }
-      if (this.unrecognizedStatusEntryLine != null) {
-        sb.append(this.unrecognizedStatusEntryLine).append("\n");
-      }
-    }
-  }
-
-  @Test()
-  public void testSampleStatus() throws DescriptorParseException {
-    StatusBuilder sb = new StatusBuilder();
-    BridgeNetworkStatus status =
-        new BridgeNetworkStatusImpl(sb.buildStatus(), sb.fileName, true);
-    assertEquals(1448127576000L, status.getPublishedMillis());
-    assertEquals(3105080L, status.getStableUptime());
-    assertEquals(2450615L, status.getStableMtbf());
-    assertEquals(55000L, status.getFastBandwidth());
-    assertEquals(98.0, status.getGuardWfu(), 0.001);
-    assertEquals(691200L, status.getGuardTk());
-    assertEquals(337000L, status.getGuardBandwidthIncludingExits());
-    assertEquals(339000L, status.getGuardBandwidthExcludingExits());
-    assertEquals(1, status.getEnoughMtbfInfo());
-    assertEquals(0, status.getIgnoringAdvertisedBws());
-    assertEquals(264, status.getStatusEntries().get(
-        "001934C20E23E812C27592A5795B6636B7F32462").getBandwidth());
-    assertTrue(status.getUnrecognizedLines().isEmpty());
-  }
-
-  @Test()
-  public void testPublishedNoLine() throws DescriptorParseException {
-    BridgeNetworkStatus status =
-        StatusBuilder.createWithPublishedLine(null);
-    assertEquals(1448127576000L, status.getPublishedMillis());
-  }
-
-  @Test()
-  public void testFlagThresholdsNoLine() throws DescriptorParseException {
-    BridgeNetworkStatus status =
-        StatusBuilder.createWithFlagThresholdsLine(null);
-    assertEquals(-1L, status.getStableUptime());
-    assertEquals(-1L, status.getStableMtbf());
-    assertEquals(-1L, status.getFastBandwidth());
-    assertEquals(-1.0, status.getGuardWfu(), 0.001);
-    assertEquals(-1L, status.getGuardTk());
-    assertEquals(-1L, status.getGuardBandwidthIncludingExits());
-    assertEquals(-1L, status.getGuardBandwidthExcludingExits());
-    assertEquals(-1, status.getEnoughMtbfInfo());
-    assertEquals(-1, status.getIgnoringAdvertisedBws());
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/ConsensusBuilder.java b/test/org/torproject/descriptor/impl/ConsensusBuilder.java
deleted file mode 100644
index 29a2d47..0000000
--- a/test/org/torproject/descriptor/impl/ConsensusBuilder.java
+++ /dev/null
@@ -1,321 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import java.util.ArrayList;
-import java.util.List;
-
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-
-/* Helper class to build a consensus based on default data and
- * modifications requested by test methods. */
-public class ConsensusBuilder {
-  String networkStatusVersionLine = "network-status-version 3";
-  protected static RelayNetworkStatusConsensus
-      createWithNetworkStatusVersionLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.networkStatusVersionLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String voteStatusLine = "vote-status consensus";
-  protected static RelayNetworkStatusConsensus
-      createWithVoteStatusLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.voteStatusLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String consensusMethodLine = "consensus-method 11";
-  protected static RelayNetworkStatusConsensus
-      createWithConsensusMethodLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.consensusMethodLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String validAfterLine = "valid-after 2011-11-30 09:00:00";
-  protected static RelayNetworkStatusConsensus
-      createWithValidAfterLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.validAfterLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String freshUntilLine = "fresh-until 2011-11-30 10:00:00";
-  protected static RelayNetworkStatusConsensus
-      createWithFreshUntilLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.freshUntilLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String validUntilLine = "valid-until 2011-11-30 12:00:00";
-  protected static RelayNetworkStatusConsensus
-      createWithValidUntilLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.validUntilLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String votingDelayLine = "voting-delay 300 300";
-  protected static RelayNetworkStatusConsensus
-      createWithVotingDelayLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.votingDelayLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  String clientVersionsLine = "client-versions 0.2.1.31,"
-      + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
-  protected static RelayNetworkStatusConsensus
-      createWithClientVersionsLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.clientVersionsLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  String serverVersionsLine = "server-versions 0.2.1.31,"
-      + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
-  protected static RelayNetworkStatusConsensus
-      createWithServerVersionsLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.serverVersionsLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String packageLines = null;
-  protected static RelayNetworkStatusConsensus
-      createWithPackageLines(String lines)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.packageLines = lines;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String knownFlagsLine = "known-flags Authority BadExit Exit "
-      + "Fast Guard HSDir Named Running Stable Unnamed V2Dir Valid";
-  protected static RelayNetworkStatusConsensus
-      createWithKnownFlagsLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.knownFlagsLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String paramsLine = "params "
-      + "CircuitPriorityHalflifeMsec=30000 bwauthbestratio=1 "
-      + "bwauthcircs=1 bwauthdescbw=0 bwauthkp=10000 bwauthpid=1 "
-      + "bwauthtd=5000 bwauthti=50000 bwauthtidecay=5000 cbtnummodes=3 "
-      + "cbtquantile=80 circwindow=1000 refuseunknownexits=1";
-  protected static RelayNetworkStatusConsensus
-      createWithParamsLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.paramsLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  List<String> dirSources = new ArrayList<>();
-  List<String> statusEntries = new ArrayList<>();
-  private String directoryFooterLine = "directory-footer";
-  protected void setDirectoryFooterLine(String line) {
-    this.directoryFooterLine = line;
-  }
-  protected static RelayNetworkStatusConsensus
-      createWithDirectoryFooterLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.directoryFooterLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private String bandwidthWeightsLine = "bandwidth-weights Wbd=285 "
-      + "Wbe=0 Wbg=0 Wbm=10000 Wdb=10000 Web=10000 Wed=1021 Wee=10000 "
-      + "Weg=1021 Wem=10000 Wgb=10000 Wgd=8694 Wgg=10000 Wgm=10000 "
-      + "Wmb=10000 Wmd=285 Wme=0 Wmg=0 Wmm=10000";
-  protected void setBandwidthWeightsLine(String line) {
-    this.bandwidthWeightsLine = line;
-  }
-  protected static RelayNetworkStatusConsensus
-      createWithBandwidthWeightsLine(String line)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.bandwidthWeightsLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-  private List<String> directorySignatures = new ArrayList<>();
-  protected void addDirectorySignature(String directorySignatureString) {
-    this.directorySignatures.add(directorySignatureString);
-  }
-  private String unrecognizedHeaderLine = null;
-  protected static RelayNetworkStatusConsensus
-      createWithUnrecognizedHeaderLine(String line,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.unrecognizedHeaderLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-        failUnrecognizedDescriptorLines);
-  }
-  private String unrecognizedDirSourceLine = null;
-  protected static RelayNetworkStatusConsensus
-      createWithUnrecognizedDirSourceLine(String line,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.unrecognizedDirSourceLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-        failUnrecognizedDescriptorLines);
-  }
-  private String unrecognizedStatusEntryLine = null;
-  protected static RelayNetworkStatusConsensus
-      createWithUnrecognizedStatusEntryLine(String line,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.unrecognizedStatusEntryLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-        failUnrecognizedDescriptorLines);
-  }
-  private String unrecognizedFooterLine = null;
-  protected static RelayNetworkStatusConsensus
-      createWithUnrecognizedFooterLine(String line,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.unrecognizedFooterLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-        failUnrecognizedDescriptorLines);
-  }
-  private String unrecognizedDirectorySignatureLine = null;
-  protected static RelayNetworkStatusConsensus
-      createWithUnrecognizedDirectorySignatureLine(String line,
-      boolean failUnrecognizedDescriptorLines)
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.unrecognizedDirectorySignatureLine = line;
-    return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-        failUnrecognizedDescriptorLines);
-  }
-
-  protected ConsensusBuilder() {
-    this.dirSources.add("dir-source tor26 "
-        + "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4 86.59.21.38 "
-        + "86.59.21.38 80 443\ncontact Peter Palfrader\nvote-digest "
-        + "0333880AA67ED7E07C11108656D0C8D6DD1C7E5D");
-    this.dirSources.add("dir-source ides "
-        + "27B6B5996C426270A5C95488AA5BCEB6BCC86956 216.224.124.114 "
-        + "216.224.124.114 9030 9090\ncontact Mike Perry "
-        + "<mikeperryTAfsckedTODorg>\nvote-digest "
-        + "1A8827ECD53184F7A771EFA9B3D30DC473FE8670");
-    this.statusEntries.add("r ANONIONROUTER "
-        + "AHhuQ8zFQJdT8l42Axxc6m6kNwI yEMZ5B/JQixNZgC1+2rLe0pR9rU "
-        + "2011-11-30 02:52:58 93.128.66.111 24051 24052\ns Exit Fast "
-        + "Named Running V2Dir Valid\nv Tor 0.2.2.34\nw "
-        + "Bandwidth=1100\np reject 25,119,135-139,6881-6999");
-    this.statusEntries.add("r Magellan AHlabo2RwnD8I7MPOIpJVVPgGJQ "
-        + "rB/7uzI4mU38bZ9cSXEy+Z/4Cuk 2011-11-30 05:37:35 "
-        + "188.177.149.216 9001 9030\ns Fast Named Running V2Dir "
-        + "Valid\nv Tor 0.2.2.34\nw Bandwidth=367\np reject 1-65535");
-    this.directorySignatures.add("directory-signature "
-        + "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4 "
-        + "3509BA5A624403A905C74DA5C8A0CEC9E0D3AF86\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "NYRcTWAMRiYYiGW0hIbzeZKU6sefg98AwwXrQUCudO8wfA1cfgttTDoscB9I"
-        + "TbOY\nr+c30jV/qQCMamTAEDGgJTw8KghI32vytupKallI1EjCOF8UvL1UnA"
-        + "LgpaR7sZ3W\n7WQZVVrWDtnYaULOEKfwnGnRC7WwE+YRSysbzwwCVs0=\n"
-        + "-----END SIGNATURE-----");
-    this.directorySignatures.add("directory-signature "
-        + "27B6B5996C426270A5C95488AA5BCEB6BCC86956 "
-        + "D5C30C15BB3F1DA27669C2D88439939E8F418FCF\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "DzFPj3vyYrCv0W3r8qDPJPlmeLnadY+drjWkdOqO66Ih/hAWBb9KcBJAX1sX"
-        + "aDA7\n/iSaDhduBXuJdcu8lbmMP8d6uYBdRjHXqWDXySUZAkSfPB4JJPNGvf"
-        + "oQA/qeby7E\n5374pPPL6WwCLJHkKtk21S9oHDmFBdlZq7JWQelWlVM=\n"
-        + "-----END SIGNATURE-----");
-  }
-  protected byte[] buildConsensus() {
-    StringBuilder sb = new StringBuilder();
-    this.appendHeader(sb);
-    this.appendDirSources(sb);
-    this.appendStatusEntries(sb);
-    this.appendFooter(sb);
-    this.appendDirectorySignatures(sb);
-    return sb.toString().getBytes();
-  }
-  private void appendHeader(StringBuilder sb) {
-    if (this.networkStatusVersionLine != null) {
-      sb.append(this.networkStatusVersionLine).append("\n");
-    }
-    if (this.voteStatusLine != null) {
-      sb.append(this.voteStatusLine).append("\n");
-    }
-    if (this.consensusMethodLine != null) {
-      sb.append(this.consensusMethodLine).append("\n");
-    }
-    if (this.validAfterLine != null) {
-      sb.append(this.validAfterLine).append("\n");
-    }
-    if (this.freshUntilLine != null) {
-      sb.append(this.freshUntilLine).append("\n");
-    }
-    if (this.validUntilLine != null) {
-      sb.append(this.validUntilLine).append("\n");
-    }
-    if (this.votingDelayLine != null) {
-      sb.append(this.votingDelayLine).append("\n");
-    }
-    if (this.clientVersionsLine != null) {
-      sb.append(this.clientVersionsLine).append("\n");
-    }
-    if (this.serverVersionsLine != null) {
-      sb.append(this.serverVersionsLine).append("\n");
-    }
-    if (this.packageLines != null) {
-      sb.append(this.packageLines).append("\n");
-    }
-    if (this.knownFlagsLine != null) {
-      sb.append(this.knownFlagsLine).append("\n");
-    }
-    if (this.paramsLine != null) {
-      sb.append(this.paramsLine).append("\n");
-    }
-    if (this.unrecognizedHeaderLine != null) {
-      sb.append(this.unrecognizedHeaderLine).append("\n");
-    }
-  }
-  private void appendDirSources(StringBuilder sb) {
-    for (String dirSource : this.dirSources) {
-      sb.append(dirSource).append("\n");
-    }
-    if (this.unrecognizedDirSourceLine != null) {
-      sb.append(this.unrecognizedDirSourceLine).append("\n");
-    }
-  }
-  private void appendStatusEntries(StringBuilder sb) {
-    for (String statusEntry : this.statusEntries) {
-      sb.append(statusEntry).append("\n");
-    }
-    if (this.unrecognizedStatusEntryLine != null) {
-      sb.append(this.unrecognizedStatusEntryLine).append("\n");
-    }
-  }
-  private void appendFooter(StringBuilder sb) {
-    if (this.directoryFooterLine != null) {
-      sb.append(this.directoryFooterLine).append("\n");
-    }
-    if (this.bandwidthWeightsLine != null) {
-      sb.append(this.bandwidthWeightsLine).append("\n");
-    }
-    if (this.unrecognizedFooterLine != null) {
-      sb.append(this.unrecognizedFooterLine).append("\n");
-    }
-  }
-  private void appendDirectorySignatures(StringBuilder sb) {
-    for (String directorySignature : this.directorySignatures) {
-      sb.append(directorySignature).append("\n");
-    }
-    if (this.unrecognizedDirectorySignatureLine != null) {
-      sb.append(this.unrecognizedDirectorySignatureLine).append("\n");
-    }
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/DescriptorCollectorImplTest.java b/test/org/torproject/descriptor/impl/DescriptorCollectorImplTest.java
deleted file mode 100644
index fde8e57..0000000
--- a/test/org/torproject/descriptor/impl/DescriptorCollectorImplTest.java
+++ /dev/null
@@ -1,134 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-
-import java.util.SortedMap;
-
-import org.junit.Test;
-
-public class DescriptorCollectorImplTest {
-
-  private static final String REMOTE_DIRECTORY_CONSENSUSES =
-      "/recent/relay-descriptors/consensuses/";
-
-  @Test()
-  public void testOneFile() {
-    String remoteFilename = "2015-05-24-12-00-00-consensus";
-    String directoryListing = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
-        + "<a href=\"" + remoteFilename + "\">"
-        + "2015-05-24-12-00-00-consensus</a></td>"
-        + "<td align=\"right\">24-May-2015 12:08  </td>"
-        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
-    SortedMap<String, Long> remoteFiles =
-        new DescriptorCollectorImpl().parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNotNull(remoteFiles);
-    assertSame(1, remoteFiles.size());
-    assertEquals(REMOTE_DIRECTORY_CONSENSUSES + remoteFilename,
-        remoteFiles.firstKey());
-    assertEquals((Long) 1432469280000L,
-        remoteFiles.get(remoteFiles.firstKey()));
-  }
-
-  @Test()
-  public void testSameFileTwoTimestampsLastWins() {
-    String remoteFilename = "2015-05-24-12-00-00-consensus";
-    String firstTimestamp = "24-May-2015 12:04";
-    String secondTimestamp = "24-May-2015 12:08";
-    String lineFormat = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
-        + "<a href=\"%s\">2015-05-24-12-00-00-consensus</a></td>"
-        + "<td align=\"right\">%s  </td>"
-        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>\n";
-    String directoryListing = String.format(lineFormat + lineFormat,
-        remoteFilename, firstTimestamp, remoteFilename, secondTimestamp);
-    SortedMap<String, Long> remoteFiles =
-        new DescriptorCollectorImpl().parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNotNull(remoteFiles);
-    assertSame(1, remoteFiles.size());
-    assertEquals(REMOTE_DIRECTORY_CONSENSUSES + remoteFilename,
-        remoteFiles.firstKey());
-    assertEquals((Long) 1432469280000L,
-        remoteFiles.get(remoteFiles.firstKey()));
-  }
-
-  @Test()
-  public void testSubDirectoryOnly() {
-    String directoryListing = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/folder.gif\" alt=\"[DIR]\"></td><td>"
-        + "<a href=\"subdir/\">subdir/</a></td>"
-        + "<td align=\"right\">27-May-2015 14:07  </td>"
-        + "<td align=\"right\">  - </td><td>&nbsp;</td></tr>";
-    DescriptorCollectorImpl collector = new DescriptorCollectorImpl();
-    SortedMap<String, Long> remoteFiles = collector.parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNotNull(remoteFiles);
-    assertTrue(remoteFiles.isEmpty());
-  }
-
-  @Test()
-  public void testParentDirectoryOnly() {
-    String directoryListing = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/back.gif\" alt=\"[DIR]\"></td><td>"
-        + "<a href=\"/recent/relay-descriptors/\">Parent Directory</a>"
-        + "</td><td>&nbsp;</td><td align=\"right\">  - </td>"
-        + "<td>&nbsp;</td></tr>";
-    DescriptorCollectorImpl collector = new DescriptorCollectorImpl();
-    SortedMap<String, Long> remoteFiles = collector.parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNotNull(remoteFiles);
-    assertTrue(remoteFiles.isEmpty());
-  }
-
-  @Test()
-  public void testUnexpectedDateFormat() {
-    String directoryListing = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
-        + "<a href=\"2015-05-24-12-00-00-consensus\">"
-        + "2015-05-24-12-00-00-consensus</a></td>"
-        + "<td align=\"right\">2015-05-24 12:08  </td>"
-        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
-    SortedMap<String, Long> remoteFiles =
-        new DescriptorCollectorImpl().parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNotNull(remoteFiles);
-    assertTrue(remoteFiles.isEmpty());
-  }
-
-  @Test()
-  public void testInvalidDate() {
-    String directoryListing = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
-        + "<a href=\"2015-05-24-12-00-00-consensus\">"
-        + "2015-05-24-12-00-00-consensus</a></td>"
-        + "<td align=\"right\">34-May-2015 12:08  </td>"
-        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
-    SortedMap<String, Long> remoteFiles =
-        new DescriptorCollectorImpl().parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNull(remoteFiles);
-  }
-
-  @Test()
-  public void testInvalidLocaleDe() {
-    String directoryListing = "<tr><td valign=\"top\">"
-        + "<img src=\"/icons/unknown.gif\" alt=\"[   ]\"></td><td>"
-        + "<a href=\"2015-05-24-12-00-00-consensus\">"
-        + "2015-05-24-12-00-00-consensus</a></td>"
-        + "<td align=\"right\">24-Mai-2015 12:08  </td>"
-        + "<td align=\"right\">1.5M</td><td>&nbsp;</td></tr>";
-    SortedMap<String, Long> remoteFiles =
-        new DescriptorCollectorImpl().parseDirectoryListing(
-        REMOTE_DIRECTORY_CONSENSUSES, directoryListing);
-    assertNull(remoteFiles);
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/ExitListImplTest.java b/test/org/torproject/descriptor/impl/ExitListImplTest.java
deleted file mode 100644
index a563857..0000000
--- a/test/org/torproject/descriptor/impl/ExitListImplTest.java
+++ /dev/null
@@ -1,131 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import org.junit.Test;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ExitListEntry;
-
-public class ExitListImplTest {
-
-  @Test()
-  public void testAnnotatedInput() throws Exception {
-    ExitListImpl result = new ExitListImpl((tordnselAnnotation + input)
-        .getBytes("US-ASCII"), fileName, false);
-    assertEquals("Expected one annotation.", 1,
-        result.getAnnotations().size());
-    assertEquals(tordnselAnnotation.substring(0, 18),
-        result.getAnnotations().get(0));
-    assertEquals(1441065722000L, result.getDownloadedMillis());
-    assertTrue("Unrecognized lines: " + result.getUnrecognizedLines(),
-        result.getUnrecognizedLines().isEmpty());
-    assertEquals("Found: " + result.getExitListEntries(), 7,
-        result.getExitListEntries().size());
-    assertEquals("Found: " + result.getEntries(), 5,
-        result.getEntries().size());
-  }
-
-  @Test()
-  public void testMultipleOldExitAddresses() throws Exception {
-    ExitListImpl result = new ExitListImpl(
-        (tordnselAnnotation + multiExitAddressInput)
-        .getBytes("US-ASCII"), fileName, false);
-    assertTrue("Unrecognized lines: " + result.getUnrecognizedLines(),
-        result.getUnrecognizedLines().isEmpty());
-    assertEquals("Found: " + result.getExitListEntries(),
-        3, result.getExitListEntries().size());
-    Map<String, Long> testMap = new HashMap();
-    testMap.put("81.7.17.171", 1441044592000L);
-    testMap.put("81.7.17.172", 1441044652000L);
-    testMap.put("81.7.17.173", 1441044712000L);
-    for (ExitListEntry ele : result.getExitListEntries()) {
-      Map<String, Long> map = ele.getExitAddresses();
-      assertEquals("Found: " + map, 1, map.size());
-      Map.Entry<String, Long> ea = map.entrySet().iterator().next();
-      assertTrue("Map: " + testMap,
-          testMap.keySet().contains(ea.getKey()));
-      assertTrue("Map: " + testMap + " exitaddress: " + ea,
-          testMap.values().contains(ea.getValue()));
-      testMap.remove(ea.getKey());
-    }
-    assertTrue("Map: " + testMap, testMap.isEmpty());
-  }
-
-  @Test()
-  public void testMultipleExitAddresses() throws Exception {
-    ExitListImpl result = new ExitListImpl(
-        (tordnselAnnotation + multiExitAddressInput)
-        .getBytes("US-ASCII"), fileName, false);
-    assertTrue("Unrecognized lines: " + result.getUnrecognizedLines(),
-        result.getUnrecognizedLines().isEmpty());
-    Map<String, Long> map = result.getEntries()
-        .iterator().next().getExitAddresses();
-    assertEquals("Found: " + map, 3, map.size());
-    assertTrue("Map: " + map, map.containsKey("81.7.17.171"));
-    assertTrue("Map: " + map, map.containsKey("81.7.17.172"));
-    assertTrue("Map: " + map, map.containsKey("81.7.17.173"));
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testInsufficientInput0() throws Exception {
-    new ExitListImpl((tordnselAnnotation + insufficientInput[0])
-        .getBytes("US-ASCII"), fileName, false);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testInsufficientInput1() throws Exception {
-    new ExitListImpl((tordnselAnnotation + insufficientInput[1])
-        .getBytes("US-ASCII"), fileName, false);
-  }
-
-  private static final String tordnselAnnotation = "@type tordnsel 1.0\n";
-  private static final String fileName = "2015-09-01-00-02-02";
-  private static final String[] insufficientInput = new String[] {
-      "Downloaded 2015-09-01 00:02:02\n"
-      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
-      + "Published 2015-08-31 16:17:30\n"
-      + "LastStatus 2015-08-31 17:03:18\n",
-      "Downloaded 2015-09-01 00:02:02\n"
-      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
-      + "LastStatus 2015-08-31 17:03:18\n"
-      + "ExitAddress 81.7.17.172 2015-08-31 18:10:52\n" };
-
-  private static final String multiExitAddressInput =
-      "Downloaded 2015-09-01 00:02:02\n"
-      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
-      + "Published 2015-08-31 16:17:30\n"
-      + "LastStatus 2015-08-31 17:03:18\n"
-      + "ExitAddress 81.7.17.171 2015-08-31 18:09:52\n"
-      + "ExitAddress 81.7.17.172 2015-08-31 18:10:52\n"
-      + "ExitAddress 81.7.17.173 2015-08-31 18:11:52\n";
-  private static final String input = "Downloaded 2015-09-01 00:02:02\n"
-      + "ExitNode 0011BD2485AD45D984EC4159C88FC066E5E3300E\n"
-      + "Published 2015-08-31 16:17:30\n"
-      + "LastStatus 2015-08-31 17:03:18\n"
-      + "ExitAddress 162.247.72.201 2015-08-31 17:09:23\n"
-      + "ExitNode 0098C475875ABC4AA864738B1D1079F711C38287\n"
-      + "Published 2015-08-31 13:59:24\n"
-      + "LastStatus 2015-08-31 15:03:20\n"
-      + "ExitAddress 162.248.160.151 2015-08-31 15:07:27\n"
-      + "ExitNode 00C4B4731658D3B4987132A3F77100CFCB190D97\n"
-      + "Published 2015-08-31 17:47:52\n"
-      + "LastStatus 2015-08-31 18:03:17\n"
-      + "ExitAddress 81.7.17.171 2015-08-31 18:09:52\n"
-      + "ExitAddress 81.7.17.172 2015-08-31 18:10:52\n"
-      + "ExitAddress 81.7.17.173 2015-08-31 18:11:52\n"
-      + "ExitNode 00F2D93EBAF2F51D6EE4DCB0F37D91D72F824B16\n"
-      + "Published 2015-08-31 14:39:05\n"
-      + "LastStatus 2015-08-31 16:02:18\n"
-      + "ExitAddress 23.239.18.57 2015-08-31 16:06:07\n"
-      + "ExitNode 011B1D1E876B2C835D01FB9D407F2E00B28077F6\n"
-      + "Published 2015-08-31 05:14:35\n"
-      + "LastStatus 2015-08-31 06:03:29\n"
-      + "ExitAddress 104.131.51.150 2015-08-31 06:04:07\n";
-}
-
diff --git a/test/org/torproject/descriptor/impl/ExtraInfoDescriptorImplTest.java b/test/org/torproject/descriptor/impl/ExtraInfoDescriptorImplTest.java
deleted file mode 100644
index 6843196..0000000
--- a/test/org/torproject/descriptor/impl/ExtraInfoDescriptorImplTest.java
+++ /dev/null
@@ -1,1737 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-
-import org.junit.Test;
-import org.torproject.descriptor.BridgeExtraInfoDescriptor;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.ExtraInfoDescriptor;
-import org.torproject.descriptor.RelayExtraInfoDescriptor;
-
-/* Test parsing of extra-info descriptors. */
-public class ExtraInfoDescriptorImplTest {
-
-  /* Helper class to build a descriptor based on default data and
-   * modifications requested by test methods. */
-  private static class DescriptorBuilder {
-    private String extraInfoLine = "extra-info chaoscomputerclub5 "
-        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26";
-    private static ExtraInfoDescriptor createWithExtraInfoLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.extraInfoLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String publishedLine = "published 2012-02-11 09:08:36";
-    private static ExtraInfoDescriptor createWithPublishedLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.publishedLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String writeHistoryLine = "write-history 2012-02-11 09:03:39 "
-        + "(900 s) 4713350144,4723824640,4710717440,4572675072";
-    private static ExtraInfoDescriptor createWithWriteHistoryLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.writeHistoryLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String readHistoryLine = "read-history 2012-02-11 09:03:39 "
-        + "(900 s) 4707695616,4699666432,4650004480,4489718784";
-    private static ExtraInfoDescriptor createWithReadHistoryLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.readHistoryLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String dirreqWriteHistoryLine = "dirreq-write-history "
-        + "2012-02-11 09:03:39 (900 s) 81281024,64996352,60625920,"
-        + "67922944";
-    private static ExtraInfoDescriptor createWithDirreqWriteHistoryLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.dirreqWriteHistoryLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String dirreqReadHistoryLine = "dirreq-read-history "
-        + "2012-02-11 09:03:39 (900 s) 17074176,16235520,16005120,"
-        + "16209920";
-    private static ExtraInfoDescriptor createWithDirreqReadHistoryLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.dirreqReadHistoryLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String geoipDbDigestLine = null;
-    private static ExtraInfoDescriptor createWithGeoipDbDigestLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.geoipDbDigestLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String geoip6DbDigestLine = null;
-    private static ExtraInfoDescriptor createWithGeoip6DbDigestLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.geoip6DbDigestLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String geoipStatsLines = null;
-    private static ExtraInfoDescriptor createWithGeoipStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.geoipStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String dirreqStatsLines = null;
-    private static ExtraInfoDescriptor createWithDirreqStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.dirreqStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String entryStatsLines = null;
-    private static ExtraInfoDescriptor createWithEntryStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.entryStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String cellStatsLines = null;
-    private static ExtraInfoDescriptor createWithCellStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.cellStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String connBiDirectLine = null;
-    private static ExtraInfoDescriptor createWithConnBiDirectLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.connBiDirectLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String exitStatsLines = null;
-    private static ExtraInfoDescriptor createWithExitStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.exitStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String bridgeStatsLines = null;
-    private static ExtraInfoDescriptor createWithBridgeStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.bridgeStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String hidservStatsLines = null;
-    private static ExtraInfoDescriptor createWithHidservStatsLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.hidservStatsLines = lines;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String unrecognizedLine = null;
-    private static ExtraInfoDescriptor createWithUnrecognizedLine(
-        String line, boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.unrecognizedLine = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(),
-          failUnrecognizedDescriptorLines);
-    }
-    private byte[] nonAsciiLineBytes = null;
-    private static ExtraInfoDescriptor createWithNonAsciiLineBytes(
-        byte[] lineBytes, boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.nonAsciiLineBytes = lineBytes;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(),
-          failUnrecognizedDescriptorLines);
-    }
-    private String routerSignatureLines = "router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
-        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
-        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
-        + "-----END SIGNATURE-----";
-    private static ExtraInfoDescriptor createWithRouterSignatureLines(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.routerSignatureLines = line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String identityEd25519Lines = null,
-        masterKeyEd25519Line = null, routerSigEd25519Line = null;
-    private static ExtraInfoDescriptor createWithEd25519Lines(
-        String identityEd25519Lines, String masterKeyEd25519Line,
-        String routerSigEd25519Line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.identityEd25519Lines = identityEd25519Lines;
-      db.masterKeyEd25519Line = masterKeyEd25519Line;
-      db.routerSigEd25519Line = routerSigEd25519Line;
-      return new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private byte[] buildDescriptor() {
-      StringBuilder sb = new StringBuilder();
-      if (this.extraInfoLine != null) {
-        sb.append(this.extraInfoLine).append("\n");
-      }
-      if (this.identityEd25519Lines != null) {
-        sb.append(this.identityEd25519Lines).append("\n");
-      }
-      if (this.masterKeyEd25519Line != null) {
-        sb.append(this.masterKeyEd25519Line).append("\n");
-      }
-      if (this.publishedLine != null) {
-        sb.append(this.publishedLine).append("\n");
-      }
-      if (this.writeHistoryLine != null) {
-        sb.append(this.writeHistoryLine).append("\n");
-      }
-      if (this.readHistoryLine != null) {
-        sb.append(this.readHistoryLine).append("\n");
-      }
-      if (this.dirreqWriteHistoryLine != null) {
-        sb.append(this.dirreqWriteHistoryLine).append("\n");
-      }
-      if (this.dirreqReadHistoryLine != null) {
-        sb.append(this.dirreqReadHistoryLine).append("\n");
-      }
-      if (this.geoipDbDigestLine != null) {
-        sb.append(this.geoipDbDigestLine).append("\n");
-      }
-      if (this.geoip6DbDigestLine != null) {
-        sb.append(this.geoip6DbDigestLine).append("\n");
-      }
-      if (this.geoipStatsLines != null) {
-        sb.append(this.geoipStatsLines).append("\n");
-      }
-      if (this.dirreqStatsLines != null) {
-        sb.append(this.dirreqStatsLines).append("\n");
-      }
-      if (this.entryStatsLines != null) {
-        sb.append(this.entryStatsLines).append("\n");
-      }
-      if (this.cellStatsLines != null) {
-        sb.append(this.cellStatsLines).append("\n");
-      }
-      if (this.connBiDirectLine != null) {
-        sb.append(this.connBiDirectLine).append("\n");
-      }
-      if (this.exitStatsLines != null) {
-        sb.append(this.exitStatsLines).append("\n");
-      }
-      if (this.bridgeStatsLines != null) {
-        sb.append(this.bridgeStatsLines).append("\n");
-      }
-      if (this.hidservStatsLines != null) {
-        sb.append(this.hidservStatsLines).append("\n");
-      }
-      if (this.unrecognizedLine != null) {
-        sb.append(this.unrecognizedLine).append("\n");
-      }
-      if (this.nonAsciiLineBytes != null) {
-        try {
-          ByteArrayOutputStream baos = new ByteArrayOutputStream();
-          baos.write(sb.toString().getBytes());
-          baos.write(this.nonAsciiLineBytes);
-          baos.write("\n".getBytes());
-          if (this.routerSignatureLines != null) {
-            baos.write(this.routerSignatureLines.getBytes());
-          }
-          return baos.toByteArray();
-        } catch (IOException e) {
-          return null;
-        }
-      }
-      if (this.routerSigEd25519Line != null) {
-        sb.append(this.routerSigEd25519Line).append("\n");
-      }
-      if (this.routerSignatureLines != null) {
-        sb.append(this.routerSignatureLines).append("\n");
-      }
-      return sb.toString().getBytes();
-    }
-  }
-
-  /* Helper class to build a set of geoip-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class GeoipStatsBuilder {
-    private String geoipStartTimeLine = "geoip-start-time 2012-02-10 "
-        + "18:32:51";
-    private static ExtraInfoDescriptor createWithGeoipStartTimeLine(
-        String line) throws DescriptorParseException {
-      GeoipStatsBuilder gsb = new GeoipStatsBuilder();
-      gsb.geoipStartTimeLine = line;
-      return DescriptorBuilder.createWithGeoipStatsLines(
-          gsb.buildGeoipStatsLines());
-    }
-    private String geoipClientOriginsLine = "geoip-client-origins "
-        + "de=1152,cn=896,us=712,it=504,ru=352,fr=208,gb=208,ir=200";
-    private static ExtraInfoDescriptor createWithGeoipClientOriginsLine(
-        String line) throws DescriptorParseException {
-      GeoipStatsBuilder gsb = new GeoipStatsBuilder();
-      gsb.geoipClientOriginsLine = line;
-      return DescriptorBuilder.createWithGeoipStatsLines(
-          gsb.buildGeoipStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithGeoipStatsLines(
-          new GeoipStatsBuilder().buildGeoipStatsLines());
-    }
-    private String buildGeoipStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.geoipStartTimeLine != null) {
-        sb.append(this.geoipStartTimeLine).append("\n");
-      }
-      if (this.geoipClientOriginsLine != null) {
-        sb.append(this.geoipClientOriginsLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  /* Helper class to build a set of dirreq-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class DirreqStatsBuilder {
-    private String dirreqStatsEndLine = "dirreq-stats-end 2012-02-11 "
-        + "00:59:53 (86400 s)";
-    private static ExtraInfoDescriptor createWithDirreqStatsEndLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqStatsEndLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV3IpsLine = "dirreq-v3-ips us=1544,de=1056,"
-        + "it=1032,fr=784,es=640,ru=440,br=312,gb=272,kr=224,sy=192";
-    private static ExtraInfoDescriptor createWithDirreqV3IpsLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV3IpsLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV2IpsLine = "dirreq-v2-ips ";
-    private static ExtraInfoDescriptor createWithDirreqV2IpsLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV2IpsLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV3ReqsLine = "dirreq-v3-reqs us=1744,de=1224,"
-        + "it=1080,fr=832,es=664,ru=536,br=344,gb=296,kr=272,in=216";
-    private static ExtraInfoDescriptor createWithDirreqV3ReqsLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV3ReqsLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV2ReqsLine = "dirreq-v2-reqs ";
-    private static ExtraInfoDescriptor createWithDirreqV2ReqsLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV2ReqsLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV3RespLine = "dirreq-v3-resp ok=10848,"
-        + "not-enough-sigs=8,unavailable=0,not-found=0,not-modified=0,"
-        + "busy=80";
-    private static ExtraInfoDescriptor createWithDirreqV3RespLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV3RespLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV2RespLine = "dirreq-v2-resp ok=0,unavailable=0,"
-        + "not-found=1576,not-modified=0,busy=0";
-    private static ExtraInfoDescriptor createWithDirreqV2RespLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV2RespLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV2ShareLine = "dirreq-v2-share 0.37%";
-    private static ExtraInfoDescriptor createWithDirreqV2ShareLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV2ShareLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV3ShareLine = "dirreq-v3-share 0.37%";
-    private static ExtraInfoDescriptor createWithDirreqV3ShareLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV3ShareLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV3DirectDlLine = "dirreq-v3-direct-dl "
-        + "complete=36,timeout=4,running=0,min=7538,d1=20224,d2=28950,"
-        + "q1=40969,d3=55786,d4=145813,md=199164,d6=267230,d7=480900,"
-        + "q3=481049,d8=531276,d9=778086,max=15079428";
-    private static ExtraInfoDescriptor createWithDirreqV3DirectDlLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV3DirectDlLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV2DirectDlLine = "dirreq-v2-direct-dl "
-        + "complete=0,timeout=0,running=0";
-    private static ExtraInfoDescriptor createWithDirreqV2DirectDlLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV2DirectDlLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV3TunneledDlLine = "dirreq-v3-tunneled-dl "
-        + "complete=10608,timeout=204,running=4,min=507,d1=20399,"
-        + "d2=27588,q1=29292,d3=30889,d4=40624,md=59967,d6=103333,"
-        + "d7=161170,q3=209415,d8=256711,d9=452503,max=23417777";
-    private static ExtraInfoDescriptor createWithDirreqV3TunneledDlLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV3TunneledDlLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private String dirreqV2TunneledDlLine = "dirreq-v2-tunneled-dl "
-        + "complete=0,timeout=0,running=0";
-    private static ExtraInfoDescriptor createWithDirreqV2TunneledDlLine(
-        String line) throws DescriptorParseException {
-      DirreqStatsBuilder dsb = new DirreqStatsBuilder();
-      dsb.dirreqV2TunneledDlLine = line;
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          dsb.buildDirreqStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithDirreqStatsLines(
-          new DirreqStatsBuilder().buildDirreqStatsLines());
-    }
-    private String buildDirreqStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.dirreqStatsEndLine != null) {
-        sb.append(this.dirreqStatsEndLine).append("\n");
-      }
-      if (this.dirreqV3IpsLine != null) {
-        sb.append(this.dirreqV3IpsLine).append("\n");
-      }
-      if (this.dirreqV2IpsLine != null) {
-        sb.append(this.dirreqV2IpsLine).append("\n");
-      }
-      if (this.dirreqV3ReqsLine != null) {
-        sb.append(this.dirreqV3ReqsLine).append("\n");
-      }
-      if (this.dirreqV2ReqsLine != null) {
-        sb.append(this.dirreqV2ReqsLine).append("\n");
-      }
-      if (this.dirreqV3RespLine != null) {
-        sb.append(this.dirreqV3RespLine).append("\n");
-      }
-      if (this.dirreqV2RespLine != null) {
-        sb.append(this.dirreqV2RespLine).append("\n");
-      }
-      if (this.dirreqV2ShareLine != null) {
-        sb.append(this.dirreqV2ShareLine).append("\n");
-      }
-      if (this.dirreqV3ShareLine != null) {
-        sb.append(this.dirreqV3ShareLine).append("\n");
-      }
-      if (this.dirreqV3DirectDlLine != null) {
-        sb.append(this.dirreqV3DirectDlLine).append("\n");
-      }
-      if (this.dirreqV2DirectDlLine != null) {
-        sb.append(this.dirreqV2DirectDlLine).append("\n");
-      }
-      if (this.dirreqV3TunneledDlLine != null) {
-        sb.append(this.dirreqV3TunneledDlLine).append("\n");
-      }
-      if (this.dirreqV2TunneledDlLine != null) {
-        sb.append(this.dirreqV2TunneledDlLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  /* Helper class to build a set of entry-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class EntryStatsBuilder {
-    private String entryStatsEndLine = "entry-stats-end 2012-02-11 "
-        + "01:59:39 (86400 s)";
-    private static ExtraInfoDescriptor createWithEntryStatsEndLine(
-        String line) throws DescriptorParseException {
-      EntryStatsBuilder esb = new EntryStatsBuilder();
-      esb.entryStatsEndLine = line;
-      return DescriptorBuilder.createWithEntryStatsLines(
-          esb.buildEntryStatsLines());
-    }
-    private String entryIpsLine = "entry-ips ir=25368,us=15744,it=14816,"
-        + "de=13256,es=8280,fr=8120,br=5176,sy=4760,ru=4504,sa=4216,"
-        + "gb=3152,pl=2928,nl=2208,kr=1856,ca=1792,ua=1272,in=1192";
-    private static ExtraInfoDescriptor createWithEntryIpsLine(
-        String line) throws DescriptorParseException {
-      EntryStatsBuilder esb = new EntryStatsBuilder();
-      esb.entryIpsLine = line;
-      return DescriptorBuilder.createWithEntryStatsLines(
-          esb.buildEntryStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithEntryStatsLines(
-          new EntryStatsBuilder().buildEntryStatsLines());
-    }
-    private String buildEntryStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.entryStatsEndLine != null) {
-        sb.append(this.entryStatsEndLine).append("\n");
-      }
-      if (this.entryIpsLine != null) {
-        sb.append(this.entryIpsLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  /* Helper class to build a set of cell-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class CellStatsBuilder {
-    private String cellStatsEndLine = "cell-stats-end 2012-02-11 "
-        + "01:59:39 (86400 s)";
-    private static ExtraInfoDescriptor createWithCellStatsEndLine(
-        String line) throws DescriptorParseException {
-      CellStatsBuilder csb = new CellStatsBuilder();
-      csb.cellStatsEndLine = line;
-      return DescriptorBuilder.createWithCellStatsLines(
-          csb.buildCellStatsLines());
-    }
-    private String cellProcessedCellsLine = "cell-processed-cells "
-        + "1441,11,6,4,2,1,1,1,1,1";
-    private static ExtraInfoDescriptor createWithCellProcessedCellsLine(
-        String line) throws DescriptorParseException {
-      CellStatsBuilder csb = new CellStatsBuilder();
-      csb.cellProcessedCellsLine = line;
-      return DescriptorBuilder.createWithCellStatsLines(
-          csb.buildCellStatsLines());
-    }
-    private String cellQueuedCellsLine = "cell-queued-cells "
-        + "3.29,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00";
-    private static ExtraInfoDescriptor createWithCellQueuedCellsLine(
-        String line) throws DescriptorParseException {
-      CellStatsBuilder csb = new CellStatsBuilder();
-      csb.cellQueuedCellsLine = line;
-      return DescriptorBuilder.createWithCellStatsLines(
-          csb.buildCellStatsLines());
-    }
-    private String cellTimeInQueueLine = "cell-time-in-queue "
-        + "524,1,1,0,0,25,0,0,0,0";
-    private static ExtraInfoDescriptor createWithCellTimeInQueueLine(
-        String line) throws DescriptorParseException {
-      CellStatsBuilder csb = new CellStatsBuilder();
-      csb.cellTimeInQueueLine = line;
-      return DescriptorBuilder.createWithCellStatsLines(
-          csb.buildCellStatsLines());
-    }
-    private String cellCircuitsPerDecileLine = "cell-circuits-per-decile "
-        + "866";
-    private static ExtraInfoDescriptor
-        createWithCellCircuitsPerDecileLine(String line)
-        throws DescriptorParseException {
-      CellStatsBuilder csb = new CellStatsBuilder();
-      csb.cellCircuitsPerDecileLine = line;
-      return DescriptorBuilder.createWithCellStatsLines(
-          csb.buildCellStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithCellStatsLines(
-          new CellStatsBuilder().buildCellStatsLines());
-    }
-    private String buildCellStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.cellStatsEndLine != null) {
-        sb.append(this.cellStatsEndLine).append("\n");
-      }
-      if (this.cellProcessedCellsLine != null) {
-        sb.append(this.cellProcessedCellsLine).append("\n");
-      }
-      if (this.cellQueuedCellsLine != null) {
-        sb.append(this.cellQueuedCellsLine).append("\n");
-      }
-      if (this.cellTimeInQueueLine != null) {
-        sb.append(this.cellTimeInQueueLine).append("\n");
-      }
-      if (this.cellCircuitsPerDecileLine != null) {
-        sb.append(this.cellCircuitsPerDecileLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  /* Helper class to build a set of exit-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class ExitStatsBuilder {
-    private String exitStatsEndLine = "exit-stats-end 2012-02-11 "
-        + "01:59:39 (86400 s)";
-    private static ExtraInfoDescriptor createWithExitStatsEndLine(
-        String line) throws DescriptorParseException {
-      ExitStatsBuilder esb = new ExitStatsBuilder();
-      esb.exitStatsEndLine = line;
-      return DescriptorBuilder.createWithExitStatsLines(
-          esb.buildExitStatsLines());
-    }
-    private String exitKibibytesWrittenLine = "exit-kibibytes-written "
-        + "25=74647,80=31370,443=20577,49755=23,52563=12,52596=1111,"
-        + "57528=4,60912=11,61351=6,64811=3365,other=2592";
-    private static ExtraInfoDescriptor createWithExitKibibytesWrittenLine(
-        String line) throws DescriptorParseException {
-      ExitStatsBuilder esb = new ExitStatsBuilder();
-      esb.exitKibibytesWrittenLine = line;
-      return DescriptorBuilder.createWithExitStatsLines(
-          esb.buildExitStatsLines());
-    }
-    private String exitKibibytesReadLine = "exit-kibibytes-read "
-        + "25=35562,80=1254256,443=110279,49755=9396,52563=1911,"
-        + "52596=648,57528=1188,60912=1427,61351=1824,64811=14,"
-        + "other=3054";
-    private static ExtraInfoDescriptor createWithExitKibibytesReadLine(
-        String line) throws DescriptorParseException {
-      ExitStatsBuilder esb = new ExitStatsBuilder();
-      esb.exitKibibytesReadLine = line;
-      return DescriptorBuilder.createWithExitStatsLines(
-          esb.buildExitStatsLines());
-    }
-    private String exitStreamsOpenedLine = "exit-streams-opened "
-        + "25=369748,80=64212,443=151660,49755=4,52563=4,52596=4,57528=4,"
-        + "60912=4,61351=4,64811=4,other=1212";
-    private static ExtraInfoDescriptor createWithExitStreamsOpenedLine(
-        String line) throws DescriptorParseException {
-      ExitStatsBuilder esb = new ExitStatsBuilder();
-      esb.exitStreamsOpenedLine = line;
-      return DescriptorBuilder.createWithExitStatsLines(
-          esb.buildExitStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithExitStatsLines(
-          new ExitStatsBuilder().buildExitStatsLines());
-    }
-    private String buildExitStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.exitStatsEndLine != null) {
-        sb.append(this.exitStatsEndLine).append("\n");
-      }
-      if (this.exitKibibytesWrittenLine != null) {
-        sb.append(this.exitKibibytesWrittenLine).append("\n");
-      }
-      if (this.exitKibibytesReadLine != null) {
-        sb.append(this.exitKibibytesReadLine).append("\n");
-      }
-      if (this.exitStreamsOpenedLine != null) {
-        sb.append(this.exitStreamsOpenedLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  /* Helper class to build a set of bridge-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class BridgeStatsBuilder {
-    private String bridgeStatsEndLine = "bridge-stats-end 2012-02-11 "
-        + "01:59:39 (86400 s)";
-    private static ExtraInfoDescriptor createWithBridgeStatsEndLine(
-        String line) throws DescriptorParseException {
-      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
-      bsb.bridgeStatsEndLine = line;
-      return DescriptorBuilder.createWithBridgeStatsLines(
-          bsb.buildBridgeStatsLines());
-    }
-    private String bridgeIpsLine = "bridge-ips ir=24,sy=16,??=8,cn=8,"
-        + "de=8,es=8,fr=8,gb=8,in=8,jp=8,kz=8,nl=8,ua=8,us=8,vn=8,za=8";
-    private static ExtraInfoDescriptor createWithBridgeIpsLine(
-        String line) throws DescriptorParseException {
-      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
-      bsb.bridgeIpsLine = line;
-      return DescriptorBuilder.createWithBridgeStatsLines(
-          bsb.buildBridgeStatsLines());
-    }
-    private String bridgeIpVersionsLine = "bridge-ip-versions v4=8,v6=16";
-    private static ExtraInfoDescriptor createWithBridgeIpVersionsLine(
-        String line) throws DescriptorParseException {
-      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
-      bsb.bridgeIpVersionsLine = line;
-      return DescriptorBuilder.createWithBridgeStatsLines(
-          bsb.buildBridgeStatsLines());
-    }
-    private String bridgeIpTransportsLine = "bridge-ip-transports "
-        + "<OR>=8,obfs2=792,obfs3=1728";
-    private static ExtraInfoDescriptor createWithBridgeIpTransportsLine(
-        String line) throws DescriptorParseException {
-      BridgeStatsBuilder bsb = new BridgeStatsBuilder();
-      bsb.bridgeIpTransportsLine = line;
-      return DescriptorBuilder.createWithBridgeStatsLines(
-          bsb.buildBridgeStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithBridgeStatsLines(
-          new BridgeStatsBuilder().buildBridgeStatsLines());
-    }
-    private String buildBridgeStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.bridgeStatsEndLine != null) {
-        sb.append(this.bridgeStatsEndLine).append("\n");
-      }
-      if (this.bridgeIpsLine != null) {
-        sb.append(this.bridgeIpsLine).append("\n");
-      }
-      if (this.bridgeIpVersionsLine != null) {
-        sb.append(this.bridgeIpVersionsLine).append("\n");
-      }
-      if (this.bridgeIpTransportsLine != null) {
-        sb.append(this.bridgeIpTransportsLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  /* Helper class to build a set of hidserv-stats lines based on default
-   * data and modifications requested by test methods. */
-  private static class HidservStatsBuilder {
-    private String hidservStatsEndLine = "hidserv-stats-end 2015-12-03 "
-        + "14:26:56 (86400 s)";
-    private static ExtraInfoDescriptor createWithHidservStatsEndLine(
-        String line) throws DescriptorParseException {
-      HidservStatsBuilder hsb = new HidservStatsBuilder();
-      hsb.hidservStatsEndLine = line;
-      return DescriptorBuilder.createWithHidservStatsLines(
-          hsb.buildHidservStatsLines());
-    }
-    private String hidservRendRelayedCellsLine =
-        "hidserv-rend-relayed-cells 36474281 delta_f=2048 epsilon=0.30 "
-        + "bin_size=1024";
-    private static ExtraInfoDescriptor
-        createWithHidservRendRelayedCellsLine(String line)
-        throws DescriptorParseException {
-      HidservStatsBuilder hsb = new HidservStatsBuilder();
-      hsb.hidservRendRelayedCellsLine = line;
-      return DescriptorBuilder.createWithHidservStatsLines(
-          hsb.buildHidservStatsLines());
-    }
-    private String hidservDirOnionsSeenLine = "hidserv-dir-onions-seen "
-        + "-3 delta_f=8 epsilon=0.30 bin_size=8";
-    private static ExtraInfoDescriptor createWithHidservDirOnionsSeenLine(
-        String line) throws DescriptorParseException {
-      HidservStatsBuilder hsb = new HidservStatsBuilder();
-      hsb.hidservDirOnionsSeenLine = line;
-      return DescriptorBuilder.createWithHidservStatsLines(
-          hsb.buildHidservStatsLines());
-    }
-    private static ExtraInfoDescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      return DescriptorBuilder.createWithHidservStatsLines(
-          new HidservStatsBuilder().buildHidservStatsLines());
-    }
-    private String buildHidservStatsLines() {
-      StringBuilder sb = new StringBuilder();
-      if (this.hidservStatsEndLine != null) {
-        sb.append(this.hidservStatsEndLine).append("\n");
-      }
-      if (this.hidservRendRelayedCellsLine != null) {
-        sb.append(this.hidservRendRelayedCellsLine).append("\n");
-      }
-      if (this.hidservDirOnionsSeenLine != null) {
-        sb.append(this.hidservDirOnionsSeenLine).append("\n");
-      }
-      String lines = sb.toString();
-      if (lines.endsWith("\n")) {
-        lines = lines.substring(0, lines.length() - 1);
-      }
-      return lines;
-    }
-  }
-
-  @Test()
-  public void testSampleDescriptor() throws DescriptorParseException {
-    DescriptorBuilder db = new DescriptorBuilder();
-    ExtraInfoDescriptor descriptor =
-        new RelayExtraInfoDescriptorImpl(db.buildDescriptor(), true);
-    assertEquals("chaoscomputerclub5", descriptor.getNickname());
-    assertEquals("A9C039A5FD02FCA06303DCFAABE25C5912C63B26",
-        descriptor.getFingerprint());
-    assertEquals(1328951316000L, descriptor.getPublishedMillis());
-    assertNotNull(descriptor.getWriteHistory());
-    assertEquals(1328951019000L, descriptor.getWriteHistory().
-        getHistoryEndMillis());
-    assertEquals(900L, descriptor.getWriteHistory().getIntervalLength());
-    assertEquals(4572675072L, (long) descriptor.getWriteHistory().
-        getBandwidthValues().get(1328951019000L));
-    assertNotNull(descriptor.getReadHistory());
-    assertNotNull(descriptor.getDirreqWriteHistory());
-    assertNotNull(descriptor.getDirreqReadHistory());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExtraInfoLineMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine(null);
-  }
-
-  @Test()
-  public void testExtraInfoOpt() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithExtraInfoLine("opt extra-info chaoscomputerclub5 "
-        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-    assertEquals("chaoscomputerclub5", descriptor.getNickname());
-    assertEquals("A9C039A5FD02FCA06303DCFAABE25C5912C63B26",
-        descriptor.getFingerprint());
-  }
-
-  @Test()
-  public void testExtraInfoNicknameTwoSpaces()
-      throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithExtraInfoLine("opt extra-info chaoscomputerclub5  "
-        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-    assertEquals("chaoscomputerclub5", descriptor.getNickname());
-    assertEquals("A9C039A5FD02FCA06303DCFAABE25C5912C63B26",
-        descriptor.getFingerprint());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExtraInfoLineNotFirst()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("geoip-db-digest "
-        + "916A3CA8B7DF61473D5AE5B21711F35F301CE9E8\n"
-        + "extra-info chaoscomputerclub5 "
-        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("extra-info  "
-        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameInvalidChar() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("extra-info "
-        + "chaoscomputerclub% A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameTooLong() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("extra-info "
-        + "chaoscomputerclub5ReallyLongNickname "
-        + "A9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintG() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("extra-info "
-        + "chaoscomputerclub5 G9C039A5FD02FCA06303DCFAABE25C5912C63B26");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooShort() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("extra-info "
-        + "chaoscomputerclub5 A9C039A5FD02FCA06303DCFAABE25C5912C6");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooLong() throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoLine("extra-info "
-        + "chaoscomputerclub5 A9C039A5FD02FCA06303DCFAABE25C5912C63B26"
-        + "A9C0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublishedMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithPublishedLine(null);
-  }
-
-  @Test()
-  public void testPublishedOpt() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithPublishedLine("opt published 2012-02-11 09:08:36");
-    assertEquals(1328951316000L, descriptor.getPublishedMillis());
-  }
-
-  @Test()
-  public void testPublishedMillis() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithPublishedLine("opt published 2012-02-11 09:08:36.123");
-    assertEquals(1328951316000L, descriptor.getPublishedMillis());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryNegativeBytes()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-02-11 09:03:39 (900 s) "
-        + "-4713350144,-4723824640,-4710717440,-4572675072");
-  }
-
-  @Test()
-  public void testReadHistoryTabInterval()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithReadHistoryLine("read-history "
-        + "2012-02-11 09:03:39 (900\ts) "
-        + "4707695616,4699666432,4650004480,4489718784");
-  }
-
-  @Test()
-  public void testReadHistoryTabIntervalBytes()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithReadHistoryLine("read-history "
-        + "2012-02-11 09:03:39 (900 s)\t"
-        + "4707695616,4699666432,4650004480,4489718784");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testReadHistoryNegativeInterval()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithReadHistoryLine("read-history "
-        + "2012-02-11 09:03:39 (-900 s) "
-        + "4707695616,4699666432,4650004480,4489718784");
-  }
-
-  @Test()
-  public void testReadHistoryNonStandardInterval()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithReadHistoryLine("read-history "
-        + "2012-02-11 09:03:39 (1800 s) "
-        + "4707695616,4699666432,4650004480,4489718784");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqWriteHistoryMissingBytesBegin()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithDirreqWriteHistoryLine(
-        "dirreq-write-history 2012-02-11 09:03:39 (900 s) "
-        + ",64996352,60625920,67922944");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqWriteHistoryMissingBytesMiddle()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithDirreqWriteHistoryLine(
-        "dirreq-write-history 2012-02-11 09:03:39 (900 s) "
-        + "81281024,,60625920,67922944");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqReadHistoryMissingBytesEnd()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithDirreqReadHistoryLine(
-        "dirreq-read-history 2012-02-11 09:03:39 (900 s) "
-        + "17074176,16235520,16005120,");
-  }
-
-  @Test()
-  public void testGeoipDbDigestValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithGeoipDbDigestLine("geoip-db-digest "
-        + "916A3CA8B7DF61473D5AE5B21711F35F301CE9E8");
-    assertEquals("916A3CA8B7DF61473D5AE5B21711F35F301CE9E8",
-        descriptor.getGeoipDbDigest());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipDbDigestTooShort()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithGeoipDbDigestLine("geoip-db-digest "
-        + "916A3CA8B7DF61473D5AE5B21711F35F301C");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipDbDigestIllegalChars()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithGeoipDbDigestLine("geoip-db-digest "
-        + "&%6A3CA8B7DF61473D5AE5B21711F35F301CE9E8");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipDbDigestMissing()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithGeoipDbDigestLine("geoip-db-digest");
-  }
-
-  @Test()
-  public void testGeoip6DbDigestValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithGeoip6DbDigestLine("geoip6-db-digest "
-        + "916A3CA8B7DF61473D5AE5B21711F35F301CE9E8");
-    assertEquals("916A3CA8B7DF61473D5AE5B21711F35F301CE9E8",
-        descriptor.getGeoip6DbDigest());
-  }
-
-  @Test()
-  public void testGeoipStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = GeoipStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1328898771000L, descriptor.getGeoipStartTimeMillis());
-    SortedMap<String, Integer> ips = descriptor.getGeoipClientOrigins();
-    assertNotNull(ips);
-    assertEquals(1152, ips.get("de").intValue());
-    assertEquals(896, ips.get("cn").intValue());
-    assertFalse(ips.containsKey("pl"));
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipStartTimeDateOnly()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipStartTimeLine("geoip-start-time "
-        + "2012-02-10");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsDash()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de-1152,cn=896,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,ir=200");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsZero()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=zero,cn=896,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,ir=200");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsNone()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=none,cn=896,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,ir=200");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsOther()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=1152,cn=896,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,other=200");
-  }
-
-  @Test()
-  public void testGeoipClientOriginsQuestionMarks()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=1152,cn=896,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,??=200");
-  }
-
-  @Test()
-  public void testGeoipClientOriginsCapital()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins DE=1152,CN=896,US=712,IT=504,RU=352,FR=208,"
-        + "GB=208,IR=200");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsMissingBegin()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins ,cn=896,us=712,it=504,ru=352,fr=208,gb=208,"
-        + "ir=200");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsMissingMiddle()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=1152,,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,ir=200");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testGeoipClientOriginsMissingEnd()
-      throws DescriptorParseException {
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=1152,cn=896,us=712,it=504,ru=352,fr=208,"
-        + "gb=208,");
-  }
-
-  @Test()
-  public void testGeoipClientOriginsDuplicate()
-      throws DescriptorParseException {
-    /* dir-spec.txt doesn't say anything about duplicate country codes, so
-     * this line is valid, even though it leads to a somewhat undefined
-     * parse result. */
-    GeoipStatsBuilder.createWithGeoipClientOriginsLine(
-        "geoip-client-origins de=1152,de=952,cn=896,us=712,it=504,"
-        + "ru=352,fr=208,gb=208,ir=200");
-  }
-
-  @Test()
-  public void testDirreqStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DirreqStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1328921993000L, descriptor.getDirreqStatsEndMillis());
-    assertEquals(86400L, descriptor.getDirreqStatsIntervalLength());
-    SortedMap<String, Integer> ips = descriptor.getDirreqV3Ips();
-    assertNotNull(ips);
-    assertEquals(1544, ips.get("us").intValue());
-    assertFalse(ips.containsKey("no"));
-    assertTrue(descriptor.getDirreqV2Ips().isEmpty());
-    SortedMap<String, Integer> reqs = descriptor.getDirreqV3Reqs();
-    assertEquals(832, reqs.get("fr").intValue());
-    assertTrue(descriptor.getDirreqV2Reqs().isEmpty());
-    SortedMap<String, Integer> resp = descriptor.getDirreqV3Resp();
-    assertEquals(10848, resp.get("ok").intValue());
-    assertEquals(8, resp.get("not-enough-sigs").intValue());
-    resp = descriptor.getDirreqV2Resp();
-    assertEquals(1576, resp.get("not-found").intValue());
-    assertEquals(0.37, descriptor.getDirreqV2Share(), 0.0001);
-    assertEquals(0.37, descriptor.getDirreqV3Share(), 0.0001);
-    SortedMap<String, Integer> dl = descriptor.getDirreqV3DirectDl();
-    assertEquals(36, dl.get("complete").intValue());
-    dl = descriptor.getDirreqV2DirectDl();
-    assertEquals(0, dl.get("timeout").intValue());
-    dl = descriptor.getDirreqV3TunneledDl();
-    assertEquals(10608, dl.get("complete").intValue());
-    dl = descriptor.getDirreqV2TunneledDl();
-    assertEquals(0, dl.get("complete").intValue());
-  }
-
-  @Test()
-  public void testDirreqStatsIntervalTwoDays()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqStatsEndLine("dirreq-stats-end "
-        + "2012-02-11 00:59:53 (172800 s)");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3IpsThreeLetterCountry()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV3IpsLine("dirreq-v3-ips "
-        + "usa=1544");
-  }
-
-  @Test()
-  public void testDirreqV2IpsDigitCountry()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV2IpsLine("dirreq-v2-ips 00=8");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3ReqsOneLetterCountry()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV3ReqsLine("dirreq-v3-reqs "
-        + "u=1744");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV2ReqsNoNumber()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV2ReqsLine("dirreq-v2-reqs us=");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3RespTwoEqualSigns()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV3RespLine("dirreq-v3-resp "
-        + "ok==10848");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV2RespNull()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV2RespLine("dirreq-v2-resp "
-        + "ok=null");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV2ShareComma()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV2ShareLine("dirreq-v2-share "
-        + "0,37%");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3ShareNoPercent()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV3ShareLine("dirreq-v3-share "
-        + "0.37");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3DirectDlSpace()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV3DirectDlLine(
-        "dirreq-v3-direct-dl complete 36");
-  }
-
-  @Test()
-  public void testDirreqV2DirectDlNegative()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV2DirectDlLine(
-        "dirreq-v2-direct-dl complete=-8");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3TunneledDlTooLarge()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV3TunneledDlLine(
-        "dirreq-v3-tunneled-dl complete=2147483648");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirreqV3TunneledDlDouble()
-      throws DescriptorParseException {
-    DirreqStatsBuilder.createWithDirreqV2TunneledDlLine(
-        "dirreq-v2-tunneled-dl complete=0.001");
-  }
-
-  @Test()
-  public void testEntryStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = EntryStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1328925579000L, descriptor.getEntryStatsEndMillis());
-    assertEquals(86400L, descriptor.getEntryStatsIntervalLength());
-    SortedMap<String, Integer> ips = descriptor.getEntryIps();
-    assertNotNull(ips);
-    assertEquals(25368, ips.get("ir").intValue());
-    assertFalse(ips.containsKey("no"));
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEntryStatsEndNoDate() throws DescriptorParseException {
-    EntryStatsBuilder.createWithEntryStatsEndLine("entry-stats-end "
-        + "01:59:39 (86400 s)");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEntryStatsIpsSemicolon()
-      throws DescriptorParseException {
-    EntryStatsBuilder.createWithEntryIpsLine("entry-ips "
-        + "ir=25368;us=15744");
-  }
-
-  @Test()
-  public void testCellStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = CellStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1328925579000L, descriptor.getCellStatsEndMillis());
-    assertEquals(86400L, descriptor.getCellStatsIntervalLength());
-    List<Integer> processedCells = descriptor.getCellProcessedCells();
-    assertEquals(10, processedCells.size());
-    assertEquals(1441, processedCells.get(0).intValue());
-    assertEquals(11, processedCells.get(1).intValue());
-    List<Double> queuedCells = descriptor.getCellQueuedCells();
-    assertEquals(10, queuedCells.size());
-    assertEquals(3.29, queuedCells.get(0), 0.001);
-    assertEquals(0.00, queuedCells.get(1), 0.001);
-    List<Integer> timeInQueue = descriptor.getCellTimeInQueue();
-    assertEquals(10, timeInQueue.size());
-    assertEquals(524, timeInQueue.get(0).intValue());
-    assertEquals(1, timeInQueue.get(1).intValue());
-    assertEquals(866, descriptor.getCellCircuitsPerDecile());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testCellStatsEndNoSeconds()
-      throws DescriptorParseException {
-    CellStatsBuilder.createWithCellStatsEndLine("cell-stats-end "
-        + "2012-02-11 01:59:39 (86400)");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testCellProcessedCellsNineComma()
-      throws DescriptorParseException {
-    CellStatsBuilder.createWithCellProcessedCellsLine(
-        "cell-processed-cells 1441,11,6,4,2,1,1,1,1,");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testCellProcessedCellsEleven()
-      throws DescriptorParseException {
-    CellStatsBuilder.createWithCellQueuedCellsLine("cell-queued-cells "
-        + "3.29,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testCellTimeInQueueDouble()
-      throws DescriptorParseException {
-    CellStatsBuilder.createWithCellTimeInQueueLine("cell-time-in-queue "
-        + "524.0,1.0,1.0,0.0,0.0,25.0,0.0,0.0,0.0,0.0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testCellCircuitsPerDecileNegative()
-      throws DescriptorParseException {
-    CellStatsBuilder.createWithCellCircuitsPerDecileLine(
-        "cell-circuits-per-decile -866");
-  }
-
-  @Test()
-  public void testConnBiDirectValid()
-      throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithConnBiDirectLine("conn-bi-direct 2012-02-11 01:59:39 "
-        + "(86400 s) 42173,1591,1310,1744");
-    assertEquals(1328925579000L,
-        descriptor.getConnBiDirectStatsEndMillis());
-    assertEquals(86400L, descriptor.getConnBiDirectStatsIntervalLength());
-    assertEquals(42173, descriptor.getConnBiDirectBelow());
-    assertEquals(1591, descriptor.getConnBiDirectRead());
-    assertEquals(1310, descriptor.getConnBiDirectWrite());
-    assertEquals(1744, descriptor.getConnBiDirectBoth());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConnBiDirectStatsFive()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithConnBiDirectLine("conn-bi-direct "
-        + "2012-02-11 01:59:39 (86400 s) 42173,1591,1310,1744,42");
-  }
-
-  @Test()
-  public void testExitStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = ExitStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1328925579000L, descriptor.getExitStatsEndMillis());
-    assertEquals(86400L, descriptor.getExitStatsIntervalLength());
-    String[] ports = new String[] { "25", "80", "443", "49755",
-        "52563", "52596", "57528", "60912", "61351", "64811", "other" };
-    int[] writtenValues = new int[] { 74647, 31370, 20577, 23, 12, 1111,
-        4, 11, 6, 3365, 2592 };
-    int i = 0;
-    for (Map.Entry<String, Long> e :
-        descriptor.getExitKibibytesWritten().entrySet()) {
-      assertEquals(ports[i], e.getKey());
-      assertEquals(writtenValues[i++], e.getValue().intValue());
-    }
-    int[] readValues = new int[] { 35562, 1254256, 110279, 9396, 1911,
-        648, 1188, 1427, 1824, 14, 3054 };
-    i = 0;
-    for (Map.Entry<String, Long> e :
-        descriptor.getExitKibibytesRead().entrySet()) {
-      assertEquals(ports[i], e.getKey());
-      assertEquals(readValues[i++], e.getValue().intValue());
-    }
-    int[] streamsValues = new int[] { 369748, 64212, 151660, 4, 4, 4, 4,
-        4, 4, 4, 1212 };
-    i = 0;
-    for (Map.Entry<String, Long> e :
-        descriptor.getExitStreamsOpened().entrySet()) {
-      assertEquals(ports[i], e.getKey());
-      assertEquals(streamsValues[i++], e.getValue().intValue());
-    }
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitStatsEndNoSeconds()
-      throws DescriptorParseException {
-    ExitStatsBuilder.createWithExitStatsEndLine("exit-stats-end "
-        + "2012-02-11 01:59 (86400 s)");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitStatsWrittenNegativePort()
-      throws DescriptorParseException {
-    ExitStatsBuilder.createWithExitKibibytesWrittenLine(
-        "exit-kibibytes-written -25=74647");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitStatsWrittenUnknown()
-      throws DescriptorParseException {
-    ExitStatsBuilder.createWithExitKibibytesWrittenLine(
-        "exit-kibibytes-written unknown=74647");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitStatsReadNegativeBytes()
-      throws DescriptorParseException {
-    ExitStatsBuilder.createWithExitKibibytesReadLine(
-        "exit-kibibytes-read 25=-35562");
-  }
-
-  @Test()
-  public void testExitStatsReadTooLarge()
-      throws DescriptorParseException {
-    ExitStatsBuilder.createWithExitKibibytesReadLine(
-        "exit-kibibytes-read other=2282907805");
-  }
-
-  @Test()
-  public void testExitStatsStreamsTooLarge()
-      throws DescriptorParseException {
-    ExitStatsBuilder.createWithExitStreamsOpenedLine(
-        "exit-streams-opened 25=2147483648");
-  }
-
-  @Test()
-  public void testBridgeStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = BridgeStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1328925579000L, descriptor.getBridgeStatsEndMillis());
-    assertEquals(86400L, descriptor.getBridgeStatsIntervalLength());
-    SortedMap<String, Integer> ips = descriptor.getBridgeIps();
-    assertNotNull(ips);
-    assertEquals(24, ips.get("ir").intValue());
-    assertEquals(16, ips.get("sy").intValue());
-    assertFalse(ips.containsKey("no"));
-    SortedMap<String, Integer> ver = descriptor.getBridgeIpVersions();
-    assertNotNull(ver);
-    assertEquals(8, ver.get("v4").intValue());
-    assertEquals(16, ver.get("v6").intValue());
-    assertFalse(ver.containsKey("v8"));
-    SortedMap<String, Integer> trans = descriptor.getBridgeIpTransports();
-    assertNotNull(trans);
-    assertEquals(8, trans.get("<OR>").intValue());
-    assertEquals(792, trans.get("obfs2").intValue());
-    assertEquals(1728, trans.get("obfs3").intValue());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBridgeStatsEndIntervalZero()
-      throws DescriptorParseException {
-    BridgeStatsBuilder.createWithBridgeStatsEndLine("bridge-stats-end "
-        + "2012-02-11 01:59:39 (0 s)");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBridgeIpsDouble()
-      throws DescriptorParseException {
-    BridgeStatsBuilder.createWithBridgeIpsLine("bridge-ips ir=24.5");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBridgeIpsNonAsciiKeyword()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithNonAsciiLineBytes(new byte[] {
-        0x14, (byte) 0xfe, 0x18,                  // non-ascii chars
-        0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x2d, // "bridge-"
-        0x69, 0x70, 0x73 }, false);               // "ips" (no newline)
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBridgeIpVersionsDouble()
-      throws DescriptorParseException {
-    BridgeStatsBuilder.createWithBridgeIpVersionsLine(
-        "bridge-ip-versions v4=24.5");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBridgeIpTransportsDouble()
-      throws DescriptorParseException {
-    BridgeStatsBuilder.createWithBridgeIpTransportsLine(
-        "bridge-ip-transports obfs2=24.5");
-  }
-
-  @Test()
-  public void testBridgeIpTransportsUnderscore()
-      throws DescriptorParseException {
-    BridgeStatsBuilder.createWithBridgeIpTransportsLine(
-        "bridge-ip-transports meek=32,obfs3_websocket=8,websocket=64");
-  }
-
-  @Test()
-  public void testHidservStatsValid() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor = HidservStatsBuilder.
-        createWithDefaultLines();
-    assertEquals(1449152816000L, descriptor.getHidservStatsEndMillis());
-    assertEquals(86400L, descriptor.getHidservStatsIntervalLength());
-    assertEquals(36474281.0, descriptor.getHidservRendRelayedCells(),
-        0.0001);
-    Map<String, Double> params =
-        descriptor.getHidservRendRelayedCellsParameters();
-    assertTrue(params.containsKey("delta_f"));
-    assertEquals(2048.0, params.remove("delta_f"), 0.0001);
-    assertTrue(params.containsKey("epsilon"));
-    assertEquals(0.3, params.remove("epsilon"), 0.0001);
-    assertTrue(params.containsKey("bin_size"));
-    assertEquals(1024.0, params.remove("bin_size"), 0.0001);
-    assertTrue(params.isEmpty());
-    assertEquals(-3.0, descriptor.getHidservDirOnionsSeen(), 0.0001);
-    params = descriptor.getHidservDirOnionsSeenParameters();
-    assertTrue(params.containsKey("delta_f"));
-    assertEquals(8.0, params.remove("delta_f"), 0.0001);
-    assertTrue(params.containsKey("epsilon"));
-    assertEquals(0.3, params.remove("epsilon"), 0.0001);
-    assertTrue(params.containsKey("bin_size"));
-    assertEquals(8.0, params.remove("bin_size"), 0.0001);
-    assertTrue(params.isEmpty());
-  }
-
-  @Test()
-  public void testHidservStatsEndLineMissing()
-      throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor =
-        HidservStatsBuilder.createWithHidservStatsEndLine(null);
-    assertEquals(-1L, descriptor.getHidservStatsEndMillis());
-    assertEquals(-1L, descriptor.getHidservStatsIntervalLength());
-  }
-
-  @Test()
-  public void testHidservRendRelayedCellsNoParams()
-        throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor =
-        HidservStatsBuilder.createWithHidservRendRelayedCellsLine(
-        "hidserv-rend-relayed-cells 36474281");
-    assertEquals(36474281.0, descriptor.getHidservRendRelayedCells(),
-        0.0001);
-    assertTrue(
-        descriptor.getHidservRendRelayedCellsParameters().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testHidservDirOnionsSeenCommaSeparatedParams()
-      throws DescriptorParseException {
-    HidservStatsBuilder.createWithHidservDirOnionsSeenLine(
-        "hidserv-dir-onions-seen -3 delta_f=8,epsilon=0.30,bin_size=8");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testHidservDirOnionsSeenNoDoubleParams()
-      throws DescriptorParseException {
-    HidservStatsBuilder.createWithHidservDirOnionsSeenLine(
-        "hidserv-dir-onions-seen -3 delta_f=A epsilon=B bin_size=C");
-  }
-
-  @Test()
-  public void testRouterSignatureOpt()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterSignatureLines("opt "
-        + "router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "crypto lines are ignored anyway\n"
-        + "-----END SIGNATURE-----");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testRouterSignatureNotLastLine()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterSignatureLines("router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
-        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
-        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
-        + "-----END SIGNATURE-----\npublished 2012-02-11 09:08:36");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    DescriptorBuilder.createWithUnrecognizedLine(unrecognizedLine, true);
-  }
-
-  @Test()
-  public void testUnrecognizedLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ExtraInfoDescriptor descriptor = DescriptorBuilder.
-        createWithUnrecognizedLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
-  }
-
-  private static final String IDENTITY_ED25519_LINES =
-      "identity-ed25519\n"
-      + "-----BEGIN ED25519 CERT-----\n"
-      + "AQQABiX1AVGv5BuzJroQXbOh6vv1nbwc5rh2S13PyRFuLhTiifK4AQAgBACBCMwr"
-      + "\n4qgIlFDIzoC9ieJOtSkwrK+yXJPKlP8ojvgkx8cGKvhokOwA1eYDombzfwHcJ1"
-      + "EV\nbhEn/6g8i7wzO3LoqefIUrSAeEExOAOmm5mNmUIzL8EtnT6JHCr/sqUTUgA="
-      + "\n"
-      + "-----END ED25519 CERT-----";
-
-  private static final String MASTER_KEY_ED25519_LINE =
-      "master-key-ed25519 gQjMK+KoCJRQyM6AvYniTrUpMKyvslyTypT/KI74JMc";
-
-  private static final String ROUTER_SIG_ED25519_LINE =
-      "router-sig-ed25519 y7WF9T2GFwkSDPZEhB55HgquIFOl5uXUFMYJPq3CXXUTKeJ"
-      + "kSrtaZUB5s34fWdHQNtl84mH4dVaFMunHnwgYAw";
-
-  @Test()
-  public void testEd25519() throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor =
-        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
-    assertEquals(IDENTITY_ED25519_LINES.substring(
-        IDENTITY_ED25519_LINES.indexOf("\n") + 1),
-        descriptor.getIdentityEd25519());
-    assertEquals(MASTER_KEY_ED25519_LINE.substring(
-        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
-        descriptor.getMasterKeyEd25519());
-    assertEquals(ROUTER_SIG_ED25519_LINE.substring(
-        ROUTER_SIG_ED25519_LINE.indexOf(" ") + 1),
-        descriptor.getRouterSignatureEd25519());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519IdentityMasterKeyMismatch()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        "master-key-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
-        ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testEd25519IdentityMissing()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(null,
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519IdentityDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES + "\n"
-        + IDENTITY_ED25519_LINES, MASTER_KEY_ED25519_LINE,
-        ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519IdentityEmptyCrypto()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines("identity-ed25519\n"
-        + "-----BEGIN ED25519 CERT-----\n-----END ED25519 CERT-----",
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testEd25519MasterKeyMissing()
-      throws DescriptorParseException {
-    ExtraInfoDescriptor descriptor =
-        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        null, ROUTER_SIG_ED25519_LINE);
-    assertEquals(MASTER_KEY_ED25519_LINE.substring(
-        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
-        descriptor.getMasterKeyEd25519());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519MasterKeyDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE + "\n" + MASTER_KEY_ED25519_LINE,
-        ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testEd25519RouterSigMissing()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE, null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519RouterSigDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE + "\n"
-        + ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testExtraInfoDigestSha256Relay()
-      throws DescriptorParseException {
-    byte[] descriptorBytes = ("extra-info Unnamed "
-        + "EA5B335055D2F03013FF030381F02B1C631EC723\n"
-        + "identity-ed25519\n"
-        + "-----BEGIN ED25519 CERT-----\n"
-        + "AQQABiZRAenzZorGtx6xapoEeaqcLLOk3uWwJXTvOVLluSXXbRSZAQAgBADLN5"
-        + "wp\nCEOrRbshSbj1NDAUgc6cxU65M/Vx1x+b5+EXbkQZ5uiyB4pphVF5kPPT1P"
-        + "SleYqM\n8j+tlKh2i6+Xr0xScSPpmtG00/D0MoRlT7ZdaaaT5iw1DWDQCZ8BHG"
-        + "lAZwU=\n"
-        + "-----END ED25519 CERT-----\n"
-        + "published 2015-12-01 04:38:12\n"
-        + "write-history 2015-12-01 01:40:37 (14400 s) 88704000,60825600,"
-        + "61747200,76953600,61516800,59443200\n"
-        + "read-history 2015-12-01 01:40:37 (14400 s) 87321600,59443200,"
-        + "59904000,74880000,60364800,58060800\n"
-        + "router-sig-ed25519 c6eUeJs/SVjun3JhmjByEeWdRDyunSMAnGVhx71JiRj"
-        + "YzR8x5IcPebylG7m10wiolFxinvw78UhrrGo9Sq5ZBw\n"
-        + "router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "oC2qFHCDOKSRoIPR86jdRxEYia390Z4d8fT0yr/1mg4RQ7lHmxlzFT6QxCswdX"
-        + "Ry\nvGNGR0wARySgyE+YKKWYn/Hp547JhhWd9Oc7BuFMY0XMvl/HOo+B9VjW+l"
-        + "nv6UBE\niqxx3C3Iw0ymohvOenyCUa/7TmsT7eVotDO57uIoGEc=\n"
-        + "-----END SIGNATURE-----\n"
-        + "").getBytes();
-    RelayExtraInfoDescriptor descriptor =
-        new RelayExtraInfoDescriptorImpl(descriptorBytes, true);
-    assertEquals("Pt1BtzfRwhYqGCDo8jjchS8nJP3ovrDyHGn+dqPbMgw",
-        descriptor.getExtraInfoDigestSha256());
-  }
-
-  @Test()
-  public void testExtraInfoDigestSha256Bridge()
-      throws DescriptorParseException {
-    byte[] descriptorBytes = ("extra-info idideditheconfig "
-        + "DC28749EC9E26E61DE492E46CD830379E9931B09\n"
-        + "master-key-ed25519 "
-        + "38FzmOIE6Mm85Ytx0MhFM6X9EuxWRUgb6HjyMGuO2AU\n"
-        + "published 2015-12-03 13:23:19\n"
-        + "write-history 2015-12-03 09:59:32 (14400 s) 53913600,52992000,"
-        + "53222400,53222400,53452800,53222400\n"
-        + "read-history 2015-12-03 09:59:32 (14400 s) 61056000,60364800,"
-        + "60364800,60134400,60595200,60364800\n"
-        + "geoip-db-digest 5BF366AD4A0572D82A1A0F6628AF8EF7725E3AB9\n"
-        + "geoip6-db-digest 212DE17D5A368DCAFA19B95F168BFFA101145A93\n"
-        + "router-digest-sha256 "
-        + "TvrqpjI7OmCtwGwair/NHUxg5ROVVQYz6/EDyXsDHR4\n"
-        + "router-digest 00B98F076B586272C3172B7F3DA29ADEE75F2ED8\n").getBytes();
-    BridgeExtraInfoDescriptor descriptor =
-        new BridgeExtraInfoDescriptorImpl(descriptorBytes, true);
-    assertEquals("TvrqpjI7OmCtwGwair/NHUxg5ROVVQYz6/EDyXsDHR4",
-        descriptor.getExtraInfoDigestSha256());
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/MicrodescriptorImplTest.java b/test/org/torproject/descriptor/impl/MicrodescriptorImplTest.java
deleted file mode 100644
index abb51db..0000000
--- a/test/org/torproject/descriptor/impl/MicrodescriptorImplTest.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package org.torproject.descriptor.impl;
-
-import org.junit.Test;
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.Microdescriptor;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-public class MicrodescriptorImplTest {
-
-  /* Helper class to build a microdescriptor based on default data and
-   * modifications requested by test methods. */
-  private static class DescriptorBuilder {
-    private String onionKeyLines = "onion-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIGJAoGBALNZ4pNsHHkl7a+kFWbBmPHNAepjjvuhjTr1TaMB3UKuCRaXJmS2Qr"
-        + "CW\nkTmINqdQUccwb3ghb7EBZfDtCUvjcwMSEsRRTVIZqVQsYj6m3n1CegOc4o"
-        + "UutXaZ\nfkyty5XOgV4Qucx9wokzTMCHlO0V0x9y0FwFsK5Nb6ugqfQLLQ6XAg"
-        + "MBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----";
-    private static Microdescriptor createWithDefaultLines()
-        throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      return new MicrodescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String ntorOnionKeyLine =
-        "ntor-onion-key PXLa7IGE+TzPDMsM5j9rFnDa37rd6kfZa5QuzqqJukw=";
-    private String idLine = "id rsa1024 bvegfGxp8k7T9QFpjPTrPaJTa/8";
-    private static Microdescriptor createWithIdLine(String line)
-        throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.idLine = line;
-      return new MicrodescriptorImpl(db.buildDescriptor(), true);
-    }
-    private byte[] buildDescriptor() {
-      StringBuilder sb = new StringBuilder();
-      if (this.onionKeyLines != null) {
-        sb.append(this.onionKeyLines).append("\n");
-      }
-      if (this.ntorOnionKeyLine != null) {
-        sb.append(this.ntorOnionKeyLine).append("\n");
-      }
-      if (this.idLine != null) {
-        sb.append(this.idLine).append("\n");
-      }
-      return sb.toString().getBytes();
-    }
-  }
-
-  @Test()
-  public void testDefaults() throws DescriptorParseException {
-    DescriptorBuilder.createWithDefaultLines();
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIdRsa1024TooShort() throws DescriptorParseException {
-    DescriptorBuilder.createWithIdLine("id rsa1024 AAAA");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIdRsa1024TooLong() throws DescriptorParseException {
-    DescriptorBuilder.createWithIdLine("id ed25519 AAAAAAAAAAAAAAAAAAAAAA"
-        + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIdRsa512() throws DescriptorParseException {
-    DescriptorBuilder.createWithIdLine("id rsa512 "
-        + "bvegfGxp8k7T9QFpjPTrPaJTa/8");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIdEd25519Duplicate() throws DescriptorParseException {
-    DescriptorBuilder.createWithIdLine(
-        "id rsa1024 bvegfGxp8k7T9QFpjPTrPaJTa/8\n"
-        + "id rsa1024 bvegfGxp8k7T9QFpjPTrPaJTa/8");
-  }
-}
diff --git a/test/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImplTest.java b/test/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImplTest.java
deleted file mode 100644
index d864337..0000000
--- a/test/org/torproject/descriptor/impl/RelayNetworkStatusConsensusImplTest.java
+++ /dev/null
@@ -1,1272 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.DirectorySignature;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-
-import org.junit.Test;
-import org.torproject.descriptor.NetworkStatusEntry;
-import org.torproject.descriptor.RelayNetworkStatusConsensus;
-
-/* TODO Add test cases for all lines starting with "opt ". */
-
-/* Test parsing of network status consensuses.  The main focus is on
- * making sure that the parser is as robust as possible and doesn't break,
- * no matter what gets fed into it.  A secondary focus is to ensure that
- * a parsed consensus is fully compatible to dir-spec.txt. */
-public class RelayNetworkStatusConsensusImplTest {
-
-  /* Helper class to build a directory source based on default data and
-   * modifications requested by test methods. */
-  private static class DirSourceBuilder {
-    private static RelayNetworkStatusConsensus
-        createWithDirSource(String dirSourceString)
-        throws DescriptorParseException {
-      ConsensusBuilder cb = new ConsensusBuilder();
-      cb.dirSources.add(dirSourceString);
-      return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-          true);
-    }
-    private String nickname = "gabelmoo";
-    private static RelayNetworkStatusConsensus
-        createWithNickname(String string)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.nickname = string;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String identity = "ED03BB616EB2F60BEC80151114BB25CEF515B226";
-    private static RelayNetworkStatusConsensus
-        createWithIdentity(String string)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.identity = string;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String hostName = "212.112.245.170";
-    private static RelayNetworkStatusConsensus
-        createWithHostName(String string)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.hostName = string;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String address = "212.112.245.170";
-    private static RelayNetworkStatusConsensus
-        createWithAddress(String string)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.address = string;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String dirPort = "80";
-    private static RelayNetworkStatusConsensus
-        createWithDirPort(String string)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.dirPort = string;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String orPort = "443";
-    private static RelayNetworkStatusConsensus
-        createWithOrPort(String string)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.orPort = string;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String contactLine = "contact 4096R/C5AA446D Sebastian Hahn "
-        + "<tor@xxxxxxxxxxxxxxxxx>";
-    private static RelayNetworkStatusConsensus
-        createWithContactLine(String line)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.contactLine = line;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String voteDigestLine =
-        "vote-digest 0F398A5834D2C139E1D92310B09F814F243354D1";
-    private static RelayNetworkStatusConsensus
-        createWithVoteDigestLine(String line)
-        throws DescriptorParseException {
-      DirSourceBuilder dsb = new DirSourceBuilder();
-      dsb.voteDigestLine = line;
-      return createWithDirSource(dsb.buildDirSource());
-    }
-    private String buildDirSource() {
-      StringBuilder sb = new StringBuilder();
-      String dirSourceLine = "dir-source " + this.nickname + " "
-          + this.identity + " " + this.hostName + " " + this.address + " "
-          + this.dirPort + " " + this.orPort;
-      sb.append(dirSourceLine).append("\n");
-      if (this.contactLine != null) {
-        sb.append(this.contactLine).append("\n");
-      }
-      if (this.voteDigestLine != null) {
-        sb.append(this.voteDigestLine).append("\n");
-      }
-      String dirSourceWithTrailingNewLine = sb.toString();
-      String dirSource = dirSourceWithTrailingNewLine.substring(0,
-          dirSourceWithTrailingNewLine.length() - 1);
-      return dirSource;
-    }
-  }
-
-  /* Helper class to build a status entry based on default data and
-   * modifications requested by test methods. */
-  private static class StatusEntryBuilder {
-    private static RelayNetworkStatusConsensus
-        createWithStatusEntry(String statusEntryString)
-        throws DescriptorParseException {
-      ConsensusBuilder cb = new ConsensusBuilder();
-      cb.statusEntries.add(statusEntryString);
-      return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-          true);
-    }
-    private String nickname = "right2privassy3";
-    private static RelayNetworkStatusConsensus
-        createWithNickname(String string)
-        throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.nickname = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String fingerprintBase64 = "ADQ6gCT3DiFHKPDFr3rODBUI8HM";
-    private static RelayNetworkStatusConsensus
-        createWithFingerprintBase64(String string)
-        throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.fingerprintBase64 = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String descriptorBase64 = "Yiti+nayuT2Efe2X1+M4nslwVuU";
-    private static RelayNetworkStatusConsensus
-        createWithDescriptorBase64(String string)
-        throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.descriptorBase64 = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String publishedString = "2011-11-29 21:34:27";
-    private static RelayNetworkStatusConsensus
-        createWithPublishedString(String string)
-        throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.publishedString = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String address = "50.63.8.215";
-    private static RelayNetworkStatusConsensus
-        createWithAddress(String string) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.address = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String orPort = "9023";
-    private static RelayNetworkStatusConsensus
-        createWithOrPort(String string) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.orPort = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String dirPort = "0";
-    private static RelayNetworkStatusConsensus
-        createWithDirPort(String string) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.dirPort = string;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String sLine = "s Exit Fast Named Running Stable Valid";
-    private static RelayNetworkStatusConsensus
-        createWithSLine(String line) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.sLine = line;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String vLine = "v Tor 0.2.1.29 (r8e9b25e6c7a2e70c)";
-    private static RelayNetworkStatusConsensus
-        createWithVLine(String line) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.vLine = line;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String wLine = "w Bandwidth=1";
-    private static RelayNetworkStatusConsensus
-        createWithWLine(String line) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.wLine = line;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String pLine = "p accept 80,1194,1220,1293";
-    private static RelayNetworkStatusConsensus
-        createWithPLine(String line) throws DescriptorParseException {
-      StatusEntryBuilder seb = new StatusEntryBuilder();
-      seb.pLine = line;
-      return createWithStatusEntry(seb.buildStatusEntry());
-    }
-    private String buildStatusEntry() {
-      StringBuilder sb = new StringBuilder();
-      String rLine = "r " + nickname + " " + fingerprintBase64 + " "
-          + descriptorBase64 + " " + publishedString + " " + address + " "
-          + orPort + " " + dirPort;
-      sb.append(rLine).append("\n");
-      if (this.sLine != null) {
-        sb.append(this.sLine).append("\n");
-      }
-      if (this.vLine != null) {
-        sb.append(this.vLine).append("\n");
-      }
-      if (this.wLine != null) {
-        sb.append(this.wLine).append("\n");
-      }
-      if (this.pLine != null) {
-        sb.append(this.pLine).append("\n");
-      }
-      String statusEntryWithTrailingNewLine = sb.toString();
-      String statusEntry = statusEntryWithTrailingNewLine.substring(0,
-          statusEntryWithTrailingNewLine.length() - 1);
-      return statusEntry;
-    }
-  }
-
-  /* Helper class to build a directory signature based on default data and
-   * modifications requested by test methods. */
-  private static class DirectorySignatureBuilder {
-    private static RelayNetworkStatusConsensus
-        createWithDirectorySignature(String directorySignatureString)
-        throws DescriptorParseException {
-      ConsensusBuilder cb = new ConsensusBuilder();
-      cb.addDirectorySignature(directorySignatureString);
-      return new RelayNetworkStatusConsensusImpl(cb.buildConsensus(),
-          true);
-    }
-    private String identity = "ED03BB616EB2F60BEC80151114BB25CEF515B226";
-    private static RelayNetworkStatusConsensus
-        createWithIdentity(String string)
-        throws DescriptorParseException {
-      DirectorySignatureBuilder dsb = new DirectorySignatureBuilder();
-      dsb.identity = string;
-      return createWithDirectorySignature(dsb.buildDirectorySignature());
-    }
-    private String signingKey =
-        "845CF1D0B370CA443A8579D18E7987E7E532F639";
-    private static RelayNetworkStatusConsensus
-        createWithSigningKey(String string)
-        throws DescriptorParseException {
-      DirectorySignatureBuilder dsb = new DirectorySignatureBuilder();
-      dsb.signingKey = string;
-      return createWithDirectorySignature(dsb.buildDirectorySignature());
-    }
-    private String buildDirectorySignature() {
-      String directorySignature = "directory-signature " + identity + " "
-          + signingKey + "\n"
-          + "-----BEGIN SIGNATURE-----\n"
-          + "gE64+/4BH43v1+7jS9FK1tu2+94at8xhVSPn4O/PpOx7b0Yb+S1hac1QHAiS"
-                + "Ll+k\n"
-          + "6OiANKzhj54WHSrUswBPrOzjmKj0OhGXSAe5nHZUFX9a1MDQLDCoZBj536X9"
-                + "P3JG\n"
-          + "z89A+wrsN17I5490y66AEvws54BYZMbgRfp8HXn/0Ss=\n"
-          + "-----END SIGNATURE-----";
-      return directorySignature;
-    }
-  }
-
-  @Test()
-  public void testSampleConsensus() throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    RelayNetworkStatusConsensus consensus =
-        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-    assertEquals(3, consensus.getNetworkStatusVersion());
-    assertEquals(11, consensus.getConsensusMethod());
-    assertEquals(1322643600000L, consensus.getValidAfterMillis());
-    assertEquals(1322647200000L, consensus.getFreshUntilMillis());
-    assertEquals(1322654400000L, consensus.getValidUntilMillis());
-    assertEquals(300L, consensus.getVoteSeconds());
-    assertEquals(300L, consensus.getDistSeconds());
-    assertTrue(consensus.getRecommendedClientVersions().contains(
-        "0.2.3.8-alpha"));
-    assertTrue(consensus.getRecommendedServerVersions().contains(
-        "0.2.3.8-alpha"));
-    assertTrue(consensus.getKnownFlags().contains("Running"));
-    assertEquals(30000, (int) consensus.getConsensusParams().get(
-        "CircuitPriorityHalflifeMsec"));
-    assertEquals("86.59.21.38", consensus.getDirSourceEntries().get(
-        "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4").getHostname());
-    assertEquals("86.59.21.38", consensus.getDirSourceEntries().get(
-        "14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4").getIp());
-    assertTrue(consensus.containsStatusEntry(
-        "00795A6E8D91C270FC23B30F388A495553E01894"));
-    assertEquals("188.177.149.216", consensus.getStatusEntry(
-        "00795A6E8D91C270FC23B30F388A495553E01894").getAddress());
-    for (DirectorySignature signature : consensus.getSignatures()) {
-      if ("14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4".equals(
-          signature.getIdentity())) {
-        assertEquals("3509BA5A624403A905C74DA5C8A0CEC9E0D3AF86",
-            signature.getSigningKeyDigest());
-      }
-    }
-    assertEquals(285, (int) consensus.getBandwidthWeights().get("Wbd"));
-    assertTrue(consensus.getUnrecognizedLines().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNoLine()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNewLine()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version 3\n");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNewLineSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version 3\n ");
-  }
-
-  @Test()
-  public void testNetworkStatusVersionPrefixLineAtChar()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "@consensus\nnetwork-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionPrefixLine()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "directory-footer\nnetwork-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionPrefixLinePoundChar()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "#consensus\nnetwork-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNoSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionOneSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersion42()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version 42");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionFourtyTwo()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version FourtyTwo");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusNoLine() throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionSpaceBefore()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithNetworkStatusVersionLine(
-        " network-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusSpaceBefore() throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine(" vote-status consensus");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusNoSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine("vote-status");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusOneSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine("vote-status ");
-  }
-
-  @Test()
-  public void testVoteStatusConsensusOneSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine("vote-status consensus ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusVote() throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine("vote-status vote");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusTheMagicVoteStatus()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithVoteStatusLine(
-        "vote-status TheMagicVoteStatus");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodNoLine()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodNoSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine("consensus-method");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodOneSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine("consensus-method ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodEleven()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine(
-        "consensus-method eleven");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodMinusOne()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine("consensus-method -1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodNinePeriod()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine("consensus-method "
-        + "999999999999999999999999999999999999999999999999999999999999");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodTwoLines()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithConsensusMethodLine(
-        "consensus-method 1\nconsensus-method 1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterNoLine() throws DescriptorParseException {
-    ConsensusBuilder.createWithValidAfterLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterNoSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithValidAfterLine("valid-after");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterOneSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithValidAfterLine("valid-after ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterLongAgo() throws DescriptorParseException {
-    ConsensusBuilder.createWithValidAfterLine("valid-after long ago");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterFeb30() throws DescriptorParseException {
-    ConsensusBuilder.createWithValidAfterLine(
-        "valid-after 2011-02-30 09:00:00");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFreshUntilNoLine() throws DescriptorParseException {
-    ConsensusBuilder.createWithFreshUntilLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFreshUntilAroundTen() throws DescriptorParseException {
-    ConsensusBuilder.createWithFreshUntilLine(
-        "fresh-until 2011-11-30 around ten");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidUntilTomorrowMorning()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithValidUntilLine(
-        "valid-until tomorrow morning");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayNoLine() throws DescriptorParseException {
-    ConsensusBuilder.createWithVotingDelayLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayNoSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithVotingDelayLine("voting-delay");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayOneSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithVotingDelayLine("voting-delay ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayTriple() throws DescriptorParseException {
-    ConsensusBuilder.createWithVotingDelayLine(
-        "voting-delay 300 300 300");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelaySingle() throws DescriptorParseException {
-    ConsensusBuilder.createWithVotingDelayLine("voting-delay 300");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayOneTwo() throws DescriptorParseException {
-    ConsensusBuilder.createWithVotingDelayLine("voting-delay one two");
-  }
-
-  @Test()
-  public void testClientServerVersionsNoLine()
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.clientVersionsLine = null;
-    cb.serverVersionsLine = null;
-    RelayNetworkStatusConsensus consensus =
-        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-    assertNull(consensus.getRecommendedClientVersions());
-    assertNull(consensus.getRecommendedServerVersions());
-  }
-
-  @Test()
-  public void testServerVersionsNoLine() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithServerVersionsLine(null);
-    assertNotNull(consensus.getRecommendedClientVersions());
-    assertNull(consensus.getRecommendedServerVersions());
-  }
-
-  @Test()
-  public void testClientVersionsNoLine() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithClientVersionsLine(null);
-    assertNull(consensus.getRecommendedClientVersions());
-    assertNotNull(consensus.getRecommendedServerVersions());
-  }
-
-  @Test()
-  public void testClientVersionsNoSpace()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithClientVersionsLine("client-versions");
-    assertNotNull(consensus.getRecommendedClientVersions());
-    assertTrue(consensus.getRecommendedClientVersions().isEmpty());
-  }
-
-  @Test()
-  public void testClientVersionsOneSpace()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithClientVersionsLine("client-versions ");
-    assertNotNull(consensus.getRecommendedClientVersions());
-    assertTrue(consensus.getRecommendedClientVersions().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testClientVersionsComma() throws DescriptorParseException {
-    ConsensusBuilder.createWithClientVersionsLine("client-versions ,");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testClientVersionsCommaVersion()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithClientVersionsLine(
-        "client-versions ,0.2.2.34");
-  }
-
-  @Test()
-  public void testPackageNone() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithPackageLines(null);
-    assertNull(consensus.getPackageLines());
-  }
-
-  @Test()
-  public void testPackageOne() throws DescriptorParseException {
-    String packageLine = "package shouldbesecond 0 http digest=digest";
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithPackageLines(packageLine);
-    assertEquals(packageLine.substring("package ".length()),
-        consensus.getPackageLines().get(0));
-  }
-
-  @Test()
-  public void testPackageTwo() throws DescriptorParseException {
-    List<String> packageLines = Arrays.asList(
-        "package shouldbesecond 0 http digest=digest",
-        "package outoforder 0 http digest=digest");
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithPackageLines(packageLines.get(0)
-        + "\n" + packageLines.get(1));
-    for (int i = 0; i < packageLines.size(); i++) {
-      assertEquals(packageLines.get(i).substring("package ".length()),
-          consensus.getPackageLines().get(i));
-    }
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPackageIncomplete() throws DescriptorParseException {
-    String packageLine = "package shouldbesecond 0 http";
-    ConsensusBuilder.createWithPackageLines(packageLine);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testKnownFlagsNoLine() throws DescriptorParseException {
-    ConsensusBuilder.createWithKnownFlagsLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testKnownFlagsNoSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithKnownFlagsLine("known-flags");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testKnownFlagsOneSpace() throws DescriptorParseException {
-    ConsensusBuilder.createWithKnownFlagsLine("known-flags ");
-  }
-
-  @Test()
-  public void testParamsNoLine() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithParamsLine(null);
-    assertNull(consensus.getConsensusParams());
-  }
-
-  @Test()
-  public void testParamsNoSpace() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithParamsLine("params");
-    assertNotNull(consensus.getConsensusParams());
-    assertTrue(consensus.getConsensusParams().isEmpty());
-  }
-
-  @Test()
-  public void testParamsOneSpace() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithParamsLine("params ");
-    assertNotNull(consensus.getConsensusParams());
-    assertTrue(consensus.getConsensusParams().isEmpty());
-  }
-
-  @Test()
-  public void testParamsThreeSpaces() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithParamsLine("params   ");
-    assertNotNull(consensus.getConsensusParams());
-    assertTrue(consensus.getConsensusParams().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testParamsNoEqualSign() throws DescriptorParseException {
-    ConsensusBuilder.createWithParamsLine("params key-value");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testParamsOneTooLargeNegative()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithParamsLine("params min=-2147483649");
-  }
-
-  @Test()
-  public void testParamsLargestNegative()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithParamsLine("params min=-2147483648");
-    assertEquals(1, consensus.getConsensusParams().size());
-    assertEquals(-2147483648,
-        (int) consensus.getConsensusParams().get("min"));
-  }
-
-  @Test()
-  public void testParamsLargestPositive()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithParamsLine("params max=2147483647");
-    assertEquals(1, consensus.getConsensusParams().size());
-    assertEquals(2147483647,
-        (int) consensus.getConsensusParams().get("max"));
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testParamsOneTooLargePositive()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithParamsLine("params max=2147483648");
-  }
-
-  @Test()
-  public void testDirSourceLegacyNickname()
-      throws DescriptorParseException {
-    DirSourceBuilder dsb = new DirSourceBuilder();
-    dsb.nickname = "gabelmoo-legacy";
-    dsb.identity = "81349FC1F2DBA2C2C11B45CB9706637D480AB913";
-    dsb.contactLine = null;
-    dsb.voteDigestLine = null;
-    RelayNetworkStatusConsensus consensus =
-        DirSourceBuilder.createWithDirSource(dsb.buildDirSource());
-    assertEquals(3, consensus.getDirSourceEntries().size());
-    assertTrue(consensus.getDirSourceEntries().get(
-        "81349FC1F2DBA2C2C11B45CB9706637D480AB913").isLegacy());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceNicknameTooLong()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithNickname("gabelmooisfinebutthisistoolong");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceIdentityTooShort()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithIdentity("ED03BB616EB2F60BEC8015111");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceIdentityTooLong()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithIdentity("ED03BB616EB2F60BEC8015111"
-        + "4BB25CEF515B226ED03BB616EB2F60BEC8");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceHostnameMissing()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithHostName("");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceAddress24() throws DescriptorParseException {
-    DirSourceBuilder.createWithAddress("212.112.245");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceAddress40() throws DescriptorParseException {
-    DirSourceBuilder.createWithAddress("212.112.245.170.123");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceDirPortMinusOne()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithDirPort("-1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceDirPort66666()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithDirPort("66666");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceDirPortOnions()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithDirPort("onions");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceOrPortOnions()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithOrPort("onions");
-  }
-
-  @Test()
-  public void testDirSourceContactNoLine()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        DirSourceBuilder.createWithContactLine(null);
-    assertNull(consensus.getDirSourceEntries().get(
-        "ED03BB616EB2F60BEC80151114BB25CEF515B226").getContactLine());
-  }
-
-  @Test()
-  public void testDirSourceContactLineNoSpace()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        DirSourceBuilder.createWithContactLine("contact");
-    assertNotNull(consensus.getDirSourceEntries().get(
-        "ED03BB616EB2F60BEC80151114BB25CEF515B226").getContactLine());
-  }
-
-  @Test()
-  public void testDirSourceContactLineOneSpace()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        DirSourceBuilder.createWithContactLine("contact ");
-    assertNotNull(consensus.getDirSourceEntries().get(
-        "ED03BB616EB2F60BEC80151114BB25CEF515B226").getContactLine());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceVoteDigestNoLine()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithVoteDigestLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceVoteDigestLineNoSpace()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithVoteDigestLine("vote-digest");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceVoteDigestLineOneSpace()
-      throws DescriptorParseException {
-    DirSourceBuilder.createWithVoteDigestLine("vote-digest ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameNotAllowedChars()
-      throws DescriptorParseException {
-    StatusEntryBuilder.createWithNickname("notAll()wed");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameTooLong() throws DescriptorParseException {
-    StatusEntryBuilder.createWithNickname("1234567890123456789tooLong");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooShort() throws DescriptorParseException {
-    StatusEntryBuilder.createWithFingerprintBase64("TooShort");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintEndsWithEqualSign()
-      throws DescriptorParseException {
-    StatusEntryBuilder.createWithFingerprintBase64(
-        "ADQ6gCT3DiFHKPDFr3rODBUI8H=");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooLong() throws DescriptorParseException {
-    StatusEntryBuilder.createWithFingerprintBase64(
-        "ADQ6gCT3DiFHKPDFr3rODBUI8HMAAAA");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDescriptorTooShort() throws DescriptorParseException {
-    StatusEntryBuilder.createWithDescriptorBase64("TooShort");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDescriptorEndsWithEqualSign()
-      throws DescriptorParseException {
-    StatusEntryBuilder.createWithDescriptorBase64(
-        "ADQ6gCT3DiFHKPDFr3rODBUI8H=");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDescriptorTooLong() throws DescriptorParseException {
-    StatusEntryBuilder.createWithDescriptorBase64(
-        "Yiti+nayuT2Efe2X1+M4nslwVuUAAAA");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublished1960() throws DescriptorParseException {
-    StatusEntryBuilder.createWithPublishedString("1960-11-29 21:34:27");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublished9999() throws DescriptorParseException {
-    StatusEntryBuilder.createWithPublishedString("9999-11-29 21:34:27");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddress256() throws DescriptorParseException {
-    StatusEntryBuilder.createWithAddress("256.63.8.215");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddress24() throws DescriptorParseException {
-    StatusEntryBuilder.createWithAddress("50.63.8/24");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddressV6() throws DescriptorParseException {
-    StatusEntryBuilder.createWithAddress("::1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPort66666() throws DescriptorParseException {
-    StatusEntryBuilder.createWithOrPort("66666");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPortEighty() throws DescriptorParseException {
-    StatusEntryBuilder.createWithOrPort("eighty");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirPortMinusOne() throws DescriptorParseException {
-    StatusEntryBuilder.createWithDirPort("-1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirPortZero() throws DescriptorParseException {
-    StatusEntryBuilder.createWithDirPort("zero");
-  }
-
-  @Test()
-  public void testSLineNoSpace() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        StatusEntryBuilder.createWithSLine("s");
-    assertTrue(consensus.getStatusEntry(
-        "00343A8024F70E214728F0C5AF7ACE0C1508F073").getFlags().isEmpty());
-  }
-
-  @Test()
-  public void testSLineOneSpace() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        StatusEntryBuilder.createWithSLine("s ");
-    assertTrue(consensus.getStatusEntry(
-        "00343A8024F70E214728F0C5AF7ACE0C1508F073").getFlags().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTwoSLines() throws DescriptorParseException {
-    StatusEntryBuilder sb = new StatusEntryBuilder();
-    sb.sLine = sb.sLine + "\n" + sb.sLine;
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.statusEntries.add(sb.buildStatusEntry());
-    new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWLineNoSpace() throws DescriptorParseException {
-    StatusEntryBuilder.createWithWLine("w");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWLineOneSpace() throws DescriptorParseException {
-    StatusEntryBuilder.createWithWLine("w ");
-  }
-
-  @Test()
-  public void testWLineWarpSeven() throws DescriptorParseException {
-    StatusEntryBuilder.createWithWLine("w Warp=7");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTwoWLines() throws DescriptorParseException {
-    StatusEntryBuilder sb = new StatusEntryBuilder();
-    sb.wLine = sb.wLine + "\n" + sb.wLine;
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.statusEntries.add(sb.buildStatusEntry());
-    new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-
-  @Test()
-  public void testWLineUnmeasured() throws DescriptorParseException {
-    StatusEntryBuilder sb = new StatusEntryBuilder();
-    sb.wLine = "w Bandwidth=42424242 Unmeasured=1";
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.statusEntries.add(sb.buildStatusEntry());
-    RelayNetworkStatusConsensus consensus =
-        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-    for (NetworkStatusEntry s : consensus.getStatusEntries().values()) {
-      if (s.getBandwidth() == 42424242L) {
-        assertTrue(s.getUnmeasured());
-      }
-    }
-  }
-
-  @Test()
-  public void testWLineNotUnmeasured() throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        StatusEntryBuilder.createWithWLine("w Bandwidth=20");
-    for (NetworkStatusEntry s : consensus.getStatusEntries().values()) {
-      assertFalse(s.getUnmeasured());
-    }
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPLineNoPolicy() throws DescriptorParseException {
-    StatusEntryBuilder.createWithPLine("p 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPLineNoPorts() throws DescriptorParseException {
-    StatusEntryBuilder.createWithPLine("p accept");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPLineNoPolicyNoPorts() throws DescriptorParseException {
-    StatusEntryBuilder.createWithPLine("p ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPLineProject() throws DescriptorParseException {
-    StatusEntryBuilder.createWithPLine("p project 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTwoPLines() throws DescriptorParseException {
-    StatusEntryBuilder sb = new StatusEntryBuilder();
-    sb.pLine = sb.pLine + "\n" + sb.pLine;
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.statusEntries.add(sb.buildStatusEntry());
-    new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-  }
-
-  @Test()
-  public void testNoStatusEntries() throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.statusEntries.clear();
-    RelayNetworkStatusConsensus consensus =
-        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-    assertFalse(consensus.containsStatusEntry(
-        "00795A6E8D91C270FC23B30F388A495553E01894"));
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirectoryFooterNoLine()
-      throws DescriptorParseException {
-    /* This breaks, because a bandwidth-weights line without a preceding
-     * directory-footer line is not allowed. */
-    ConsensusBuilder.createWithDirectoryFooterLine(null);
-  }
-
-  @Test()
-  public void testDirectoryFooterMissing()
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.setDirectoryFooterLine(null);
-    cb.setBandwidthWeightsLine(null);
-    /* This does not break, because directory footers were optional before
-     * consensus method 9. */
-    RelayNetworkStatusConsensus consensus =
-        new RelayNetworkStatusConsensusImpl(cb.buildConsensus(), true);
-    assertNull(consensus.getBandwidthWeights());
-  }
-
-  @Test()
-  public void testDirectoryFooterLineSpace()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithDirectoryFooterLine("directory-footer ");
-  }
-
-  @Test()
-  public void testBandwidthWeightsNoLine()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus =
-        ConsensusBuilder.createWithBandwidthWeightsLine(null);
-    assertNull(consensus.getBandwidthWeights());
-  }
-
-  @Test()
-  public void testBandwidthWeightsLineNoSpace()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithBandwidthWeightsLine("bandwidth-weights");
-    assertNotNull(consensus.getBandwidthWeights());
-  }
-
-  @Test()
-  public void testBandwidthWeightsLineOneSpace()
-      throws DescriptorParseException {
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithBandwidthWeightsLine("bandwidth-weights ");
-    assertNotNull(consensus.getBandwidthWeights());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBandwidthWeightsLineNoEqualSign()
-      throws DescriptorParseException {
-    ConsensusBuilder.createWithBandwidthWeightsLine(
-        "bandwidth-weights Wbd-285");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirectorySignatureIdentityTooShort()
-      throws DescriptorParseException {
-    DirectorySignatureBuilder.createWithIdentity("ED03BB616EB2F60");
-  }
-
-  @Test()
-  public void testDirectorySignatureIdentityTooLong()
-      throws DescriptorParseException {
-    /* This hex string has an unusual length of 58 hex characters, but
-     * dir-spec.txt only requires a hex string, and we can't know all hex
-     * string lengths for all future digest algorithms, so let's just
-     * accept this. */
-    DirectorySignatureBuilder.createWithIdentity(
-        "ED03BB616EB2F60BEC80151114BB25CEF515B226ED03BB616EB2F60BEC");
-  }
-
-  @Test()
-  public void testDirectorySignatureSigningKeyTooShort()
-      throws DescriptorParseException {
-    /* See above, we accept this hex string even though it's unusually
-     * short. */
-    DirectorySignatureBuilder.createWithSigningKey("845CF1D0B370CA");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirectorySignatureSigningKeyTooShortOddNumber()
-      throws DescriptorParseException {
-    /* We don't accept this hex string, because it contains an odd number
-     * of hex characters. */
-    DirectorySignatureBuilder.createWithSigningKey("845");
-  }
-
-  @Test()
-  public void testDirectorySignatureSigningKeyTooLong()
-      throws DescriptorParseException {
-    /* See above, we accept this hex string even though it's unusually
-     * long. */
-    DirectorySignatureBuilder.createWithSigningKey(
-        "845CF1D0B370CA443A8579D18E7987E7E532F639845CF1D0B370CA443A");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNonAsciiByte20() throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    byte[] consensusBytes = cb.buildConsensus();
-    consensusBytes[20] = (byte) 200;
-    new RelayNetworkStatusConsensusImpl(consensusBytes, true);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNonAsciiByteMinusOne()
-      throws DescriptorParseException {
-    ConsensusBuilder cb = new ConsensusBuilder();
-    cb.networkStatusVersionLine = "Xnetwork-status-version 3";
-    byte[] consensusBytes = cb.buildConsensus();
-    consensusBytes[0] = (byte) 200;
-    new RelayNetworkStatusConsensusImpl(consensusBytes, true);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedHeaderLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ConsensusBuilder.createWithUnrecognizedHeaderLine(unrecognizedLine,
-        true);
-  }
-
-  @Test()
-  public void testUnrecognizedHeaderLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithUnrecognizedHeaderLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedDirSourceLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ConsensusBuilder.createWithUnrecognizedDirSourceLine(unrecognizedLine,
-        true);
-  }
-
-  @Test()
-  public void testUnrecognizedDirSourceLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithUnrecognizedDirSourceLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedStatusEntryLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ConsensusBuilder.createWithUnrecognizedStatusEntryLine(
-        unrecognizedLine, true);
-  }
-
-  @Test()
-  public void testUnrecognizedStatusEntryLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithUnrecognizedStatusEntryLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedDirectoryFooterLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ConsensusBuilder.createWithUnrecognizedFooterLine(unrecognizedLine,
-        true);
-  }
-
-  @Test()
-  public void testUnrecognizedDirectoryFooterLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithUnrecognizedFooterLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedDirectorySignatureLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ConsensusBuilder.createWithUnrecognizedDirectorySignatureLine(
-        unrecognizedLine, true);
-  }
-
-  @Test()
-  public void testUnrecognizedDirectorySignatureLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusConsensus consensus = ConsensusBuilder.
-        createWithUnrecognizedDirectorySignatureLine(unrecognizedLine,
-        false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, consensus.getUnrecognizedLines());
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/RelayNetworkStatusVoteImplTest.java b/test/org/torproject/descriptor/impl/RelayNetworkStatusVoteImplTest.java
deleted file mode 100644
index 1c840f5..0000000
--- a/test/org/torproject/descriptor/impl/RelayNetworkStatusVoteImplTest.java
+++ /dev/null
@@ -1,1373 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import org.torproject.descriptor.DirectorySignature;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import org.junit.Test;
-import org.torproject.descriptor.RelayNetworkStatusVote;
-
-/* TODO Add test cases for all lines starting with "opt ". */
-
-/* Test parsing of network status votes.  Some of the vote-parsing code is
- * already tested in the consensus-parsing tests.  The tests in this class
- * focus on the differences between votes and consensuses that are mostly
- * in the directory header. */
-public class RelayNetworkStatusVoteImplTest {
-
-  /* Helper class to build a vote based on default data and modifications
-   * requested by test methods. */
-  private static class VoteBuilder {
-    private String networkStatusVersionLine = "network-status-version 3";
-    private static RelayNetworkStatusVote
-        createWithNetworkStatusVersionLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.networkStatusVersionLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String voteStatusLine = "vote-status vote";
-    private static RelayNetworkStatusVote
-        createWithVoteStatusLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.voteStatusLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String consensusMethodsLine =
-        "consensus-methods 1 2 3 4 5 6 7 8 9 10 11";
-    private static RelayNetworkStatusVote
-        createWithConsensusMethodsLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.consensusMethodsLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String publishedLine = "published 2011-11-30 08:50:01";
-    private static RelayNetworkStatusVote
-        createWithPublishedLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.publishedLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String validAfterLine = "valid-after 2011-11-30 09:00:00";
-    private static RelayNetworkStatusVote
-        createWithValidAfterLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.validAfterLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String freshUntilLine = "fresh-until 2011-11-30 10:00:00";
-    private static RelayNetworkStatusVote
-        createWithFreshUntilLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.freshUntilLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String validUntilLine = "valid-until 2011-11-30 12:00:00";
-    private static RelayNetworkStatusVote
-        createWithValidUntilLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.validUntilLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String votingDelayLine = "voting-delay 300 300";
-    private static RelayNetworkStatusVote
-        createWithVotingDelayLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.votingDelayLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String clientVersionsLine = "client-versions 0.2.1.31,"
-        + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
-    private static RelayNetworkStatusVote
-        createWithClientVersionsLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.clientVersionsLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String serverVersionsLine = "server-versions 0.2.1.31,"
-        + "0.2.2.34,0.2.3.6-alpha,0.2.3.7-alpha,0.2.3.8-alpha";
-    private static RelayNetworkStatusVote
-        createWithServerVersionsLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.serverVersionsLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String packageLines = null;
-    protected static RelayNetworkStatusVote
-        createWithPackageLines(String lines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.packageLines = lines;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String knownFlagsLine = "known-flags Authority BadExit Exit "
-        + "Fast Guard HSDir Named Running Stable Unnamed V2Dir Valid";
-    private static RelayNetworkStatusVote
-        createWithKnownFlagsLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.knownFlagsLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String flagThresholdsLine = "flag-thresholds "
-        + "stable-uptime=693369 stable-mtbf=153249 fast-speed=40960 "
-        + "guard-wfu=94.669% guard-tk=691200 guard-bw-inc-exits=174080 "
-        + "guard-bw-exc-exits=184320 enough-mtbf=1";
-    private static RelayNetworkStatusVote
-        createWithFlagThresholdsLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.flagThresholdsLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String paramsLine = "params "
-        + "CircuitPriorityHalflifeMsec=30000 bwauthbestratio=1 "
-        + "bwauthcircs=1 bwauthdescbw=0 bwauthkp=10000 bwauthpid=1 "
-        + "bwauthtd=5000 bwauthti=50000 bwauthtidecay=5000 cbtnummodes=3 "
-        + "cbtquantile=80 circwindow=1000 refuseunknownexits=1";
-    private static RelayNetworkStatusVote
-        createWithParamsLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.paramsLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirSourceLine = "dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80";
-    private static RelayNetworkStatusVote
-        createWithDirSourceLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirSourceLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String contactLine = "contact 4096R/E012B42D Jacob Appelbaum "
-        + "<jacob@xxxxxxxxxxxxx>";
-    private static RelayNetworkStatusVote
-        createWithContactLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.contactLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String legacyDirKeyLine = null;
-    private static RelayNetworkStatusVote
-        createWithLegacyDirKeyLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.legacyDirKeyLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirKeyCertificateVersionLine =
-        "dir-key-certificate-version 3";
-    private static RelayNetworkStatusVote
-        createWithDirKeyCertificateVersionLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirKeyCertificateVersionLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String fingerprintLine = "fingerprint "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C";
-    private static RelayNetworkStatusVote
-        createWithFingerprintLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.fingerprintLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirKeyPublishedLine = "dir-key-published 2011-04-27 "
-        + "05:34:37";
-    private static RelayNetworkStatusVote
-        createWithDirKeyPublishedLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirKeyPublishedLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirKeyExpiresLine = "dir-key-expires 2012-04-27 "
-        + "05:34:37";
-    private static RelayNetworkStatusVote
-        createWithDirKeyExpiresLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirKeyExpiresLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirIdentityKeyLines = "dir-identity-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIIBigKCAYEAtKpuLgVK25sfScjsxfVU1ljofrDygt9GP7bNJl/rghX42KUT97"
-        + "5W\nrGp/fbhF7p+FcKCzNOhJFINQbRf/5E3lN8mzoamIU43QqQ9RRVf94688Us"
-        + "azVsAN\nNVT0v9J0cr387WePjenRuIE1MmiP0nmw/XdvbPTayqax7VYlcUMXGH"
-        + "l8DnWix1EN\nRwmeig+JBte0JS12oo2HG9zcSfjLJVjY6ZmvRrVycXiRxGc/Jg"
-        + "NlSrV4cxUNykaB\nJ6pO6J499OZfQu7m1vAPTENrVJ4yEfRGRwFIY+d/s8BkKc"
-        + "aiWtXAfTe31uBI6GEH\nmS3HNu1JVSuoaUiQIvVYDLMfBvMcNyAx97UT1l6E0T"
-        + "n6a7pgChrquGwXai1xGzk8\n58aXwdSFoFBSTCkyemopq5H20p/nkPAO0pHL1k"
-        + "TvcaKz9CEj4XcKm+kOmzejYmIa\nkbWNcRpXPiUZ+xmwGtsq30xrzqiONmERkx"
-        + "qlmf7bVQPFvh3Kz6hGcmTBhTbHSe9h\nzDgmdaTNn3EHAgMBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----";
-    private static RelayNetworkStatusVote
-        createWithDirIdentityKeyLines(String lines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirIdentityKeyLines = lines;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirSigningKeyLines = "dir-signing-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIGJAoGBAN05qyHFQlTqykMP8yLuD4G2UuYulD4Xs8iSX5uqF+WGsUA1E4zZh4"
-        + "8h\nDFj8+drFiCu3EqhMEmVG4ACtJK2uz6D1XohUsbPWTR6LSnWJ8q6/zfTSLu"
-        + "mBGsN7\nPUXyMNjwRKL6UvrcbYk1d2mRBLO7SAP/sFW5fHhIBVeLIWrzQ19rAg"
-        + "MBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----";
-    private static RelayNetworkStatusVote
-        createWithDirSigningKeyLines(String lines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirSigningKeyLines = lines;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirKeyCrosscertLines = "dir-key-crosscert\n"
-        + "-----BEGIN ID SIGNATURE-----\n"
-        + "rPBFn6IJ6TvAHj4pSwlg+RTn1fP89JGSVa08wuyJr5dAvZsdakQXvRjamT9oJU"
-        + "aZ\nnY5Rl/tRlGuSQ0BglTPPKoXdKERK0FUr9f0EKrQy7NDUgE2j9losiRuyKz"
-        + "hA3neZ\nK4yF8bhqAwM51u7fzAhIjNeRif9c04rhFJJCseco84w=\n"
-        + "-----END ID SIGNATURE-----";
-    private static RelayNetworkStatusVote
-        createWithDirKeyCrosscertLines(String lines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirKeyCrosscertLines = lines;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String dirKeyCertificationLines = "dir-key-certification\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "hPSh6FuohNF5ccjiMbkvr8cZJwGFuL11cNtwN9k0X3pUdFZVATIEkqBe7z+rE2"
-        + "PX\nPw+BGyC6wYAieoTVIhLpwKqd7DXLYjuhPZ28+7MQaDL01AqYeRp5PT01Px"
-        + "rFY0Um\nlVf95uqUitgvDT76Ne4ExWk6UvGlYB9OBgBySZz8VWe9znoMqb0uHn"
-        + "/p8IzqTApT\nAxRWXBHClntMeRqtGxaj8DcdJFn8yMxQiZG7MfDg2sq2ySPJyG"
-        + "lN+neoVDVhZiDI\n9LTNmw60gWlUp2erFeam8Mo1ZBC4DPNjQEm6QeHZFZMkhD"
-        + "uO6SwS/FL712A42+Co\nYtMaVot/p5FG2ZSBXbgl2XP5/z8ELnpmXqMbPAoWRo"
-        + "3BPNSJkIQQNog8Q5ZrK+av\nZDw5eGPltGKsXOkvuzIMM8nBeAnDPDgYvzrIFO"
-        + "bEGbvY/P8mzVAZxp3Yz+sRtNel\nC1SWz/Fx+Saex5oI7DJ3xtSD4XqKb/wYwZ"
-        + "FT8IxDYq1t2tFXdHxd4QPRVcvc0zYC\n"
-        + "-----END SIGNATURE-----";
-    private static RelayNetworkStatusVote
-        createWithDirKeyCertificationLines(String lines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.dirKeyCertificationLines = lines;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private List<String> statusEntries = null;
-    private static RelayNetworkStatusVote createWithStatusEntries(
-        List<String> statusEntries) throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.statusEntries = statusEntries;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String directoryFooterLine = "directory-footer";
-    private static RelayNetworkStatusVote
-        createWithDirectoryFooterLine(String line)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.directoryFooterLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String directorySignatureLines = "directory-signature "
-          + "80550987E1D626E3EBA5E5E75A458DE0626D088C "
-          + "EEB9299D295C1C815E289FBF2F2BBEA5F52FDD19\n"
-          + "-----BEGIN SIGNATURE-----\n"
-          + "iHEU3Iidya5RIrjyYgv8tlU0R+rF56/3/MmaaZi0a67e7ZkISfQ4dghScHxn"
-          + "F3Yh\nrXVaaoP07r6Ta+s0g1Zijm3lms50Nk/4tV2p8Y63c3F4Q3DAnK40Oi"
-          + "kfOIwEj+Ny\n+zBRQssP3hPhTPOj/A7o3mZZwtL6x1sxpeu/nME1l5E=\n"
-          + "-----END SIGNATURE-----";
-    private static RelayNetworkStatusVote
-        createWithDirectorySignatureLines(String lines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.directorySignatureLines = lines;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    }
-    private String unrecognizedHeaderLine = null;
-    protected static RelayNetworkStatusVote
-        createWithUnrecognizedHeaderLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.unrecognizedHeaderLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
-          failUnrecognizedDescriptorLines);
-    }
-    private String unrecognizedDirSourceLine = null;
-    protected static RelayNetworkStatusVote
-        createWithUnrecognizedDirSourceLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.unrecognizedDirSourceLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
-          failUnrecognizedDescriptorLines);
-    }
-    private String unrecognizedStatusEntryLine = null;
-    protected static RelayNetworkStatusVote
-        createWithUnrecognizedStatusEntryLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.unrecognizedStatusEntryLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
-          failUnrecognizedDescriptorLines);
-    }
-    private String unrecognizedFooterLine = null;
-    protected static RelayNetworkStatusVote
-        createWithUnrecognizedFooterLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.unrecognizedFooterLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
-          failUnrecognizedDescriptorLines);
-    }
-    private String unrecognizedDirectorySignatureLine = null;
-    protected static RelayNetworkStatusVote
-        createWithUnrecognizedDirectorySignatureLine(String line,
-        boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      VoteBuilder vb = new VoteBuilder();
-      vb.unrecognizedDirectorySignatureLine = line;
-      return new RelayNetworkStatusVoteImpl(vb.buildVote(),
-          failUnrecognizedDescriptorLines);
-    }
-
-    private VoteBuilder() {
-      if (this.statusEntries != null) {
-        return;
-      }
-      this.statusEntries = new ArrayList<>();
-      this.statusEntries.add("r right2privassy3 "
-          + "ADQ6gCT3DiFHKPDFr3rODBUI8HM lJY5Vf7kXec+VdkGW2flEsfkFC8 "
-          + "2011-11-12 00:03:40 50.63.8.215 9023 0\n"
-          + "s Exit Fast Guard Running Stable Valid\n"
-          + "opt v Tor 0.2.1.29 (r8e9b25e6c7a2e70c)\n"
-          + "w Bandwidth=297 Measured=73\n"
-          + "p accept 80,1194,1220,1293,1500,1533,1677,1723,1863,"
-          + "2082-2083,2086-2087,2095-2096,2102-2104,3128,3389,3690,4321,"
-          + "4643,5050,5190,5222-5223,5228,5900,6660-6669,6679,6697,8000,"
-          + "8008,8074,8080,8087-8088,8443,8888,9418,9999-10000,19294,"
-          + "19638\n"
-          + "m 8,9,10,11 "
-          + "sha256=9ciEx9t0McXk9A06I7qwN7pxuNOdpCP64RV/6cx2Zkc");
-    }
-    private byte[] buildVote() {
-      StringBuilder sb = new StringBuilder();
-      this.appendHeader(sb);
-      this.appendDirSource(sb);
-      this.appendStatusEntries(sb);
-      this.appendFooter(sb);
-      this.appendDirectorySignature(sb);
-      return sb.toString().getBytes();
-    }
-    private void appendHeader(StringBuilder sb) {
-      if (this.networkStatusVersionLine != null) {
-        sb.append(this.networkStatusVersionLine).append("\n");
-      }
-      if (this.voteStatusLine != null) {
-        sb.append(this.voteStatusLine).append("\n");
-      }
-      if (this.consensusMethodsLine != null) {
-        sb.append(this.consensusMethodsLine).append("\n");
-      }
-      if (this.publishedLine != null) {
-        sb.append(this.publishedLine).append("\n");
-      }
-      if (this.validAfterLine != null) {
-        sb.append(this.validAfterLine).append("\n");
-      }
-      if (this.freshUntilLine != null) {
-        sb.append(this.freshUntilLine).append("\n");
-      }
-      if (this.validUntilLine != null) {
-        sb.append(this.validUntilLine).append("\n");
-      }
-      if (this.votingDelayLine != null) {
-        sb.append(this.votingDelayLine).append("\n");
-      }
-      if (this.clientVersionsLine != null) {
-        sb.append(this.clientVersionsLine).append("\n");
-      }
-      if (this.serverVersionsLine != null) {
-        sb.append(this.serverVersionsLine).append("\n");
-      }
-      if (this.packageLines != null) {
-        sb.append(this.packageLines).append("\n");
-      }
-      if (this.knownFlagsLine != null) {
-        sb.append(this.knownFlagsLine).append("\n");
-      }
-      if (this.flagThresholdsLine != null) {
-        sb.append(this.flagThresholdsLine).append("\n");
-      }
-      if (this.paramsLine != null) {
-        sb.append(this.paramsLine).append("\n");
-      }
-      if (this.unrecognizedHeaderLine != null) {
-        sb.append(this.unrecognizedHeaderLine).append("\n");
-      }
-    }
-    private void appendDirSource(StringBuilder sb) {
-      if (this.dirSourceLine != null) {
-        sb.append(this.dirSourceLine).append("\n");
-      }
-      if (this.contactLine != null) {
-        sb.append(this.contactLine).append("\n");
-      }
-      if (this.legacyDirKeyLine != null) {
-        sb.append(this.legacyDirKeyLine).append("\n");
-      }
-      if (this.dirKeyCertificateVersionLine != null) {
-        sb.append(this.dirKeyCertificateVersionLine).append("\n");
-      }
-      if (this.fingerprintLine != null) {
-        sb.append(this.fingerprintLine).append("\n");
-      }
-      if (this.dirKeyPublishedLine != null) {
-        sb.append(this.dirKeyPublishedLine).append("\n");
-      }
-      if (this.dirKeyExpiresLine != null) {
-        sb.append(this.dirKeyExpiresLine).append("\n");
-      }
-      if (this.dirIdentityKeyLines != null) {
-        sb.append(this.dirIdentityKeyLines).append("\n");
-      }
-      if (this.dirSigningKeyLines != null) {
-        sb.append(this.dirSigningKeyLines).append("\n");
-      }
-      if (this.dirKeyCrosscertLines != null) {
-        sb.append(this.dirKeyCrosscertLines).append("\n");
-      }
-      if (this.dirKeyCertificationLines != null) {
-        sb.append(this.dirKeyCertificationLines).append("\n");
-      }
-      if (this.unrecognizedDirSourceLine != null) {
-        sb.append(this.unrecognizedDirSourceLine).append("\n");
-      }
-    }
-    private void appendStatusEntries(StringBuilder sb) {
-      for (String statusEntry : this.statusEntries) {
-        sb.append(statusEntry).append("\n");
-      }
-      if (this.unrecognizedStatusEntryLine != null) {
-        sb.append(this.unrecognizedStatusEntryLine).append("\n");
-      }
-    }
-    private void appendFooter(StringBuilder sb) {
-      if (this.directoryFooterLine != null) {
-        sb.append(this.directoryFooterLine).append("\n");
-      }
-      if (this.unrecognizedFooterLine != null) {
-        sb.append(this.unrecognizedFooterLine).append("\n");
-      }
-    }
-    private void appendDirectorySignature(StringBuilder sb) {
-      if (this.directorySignatureLines != null) {
-        sb.append(directorySignatureLines).append("\n");
-      }
-      if (this.unrecognizedDirectorySignatureLine != null) {
-        sb.append(this.unrecognizedDirectorySignatureLine).append("\n");
-      }
-    }
-  }
-
-  @Test()
-  public void testSampleVote() throws DescriptorParseException {
-    VoteBuilder vb = new VoteBuilder();
-    RelayNetworkStatusVote vote =
-        new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    assertEquals(3, vote.getNetworkStatusVersion());
-    List<Integer> consensusMethods = Arrays.asList(
-        new Integer[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11});
-    assertEquals(vote.getConsensusMethods(), consensusMethods);
-    assertEquals(1322643001000L, vote.getPublishedMillis());
-    assertEquals(1322643600000L, vote.getValidAfterMillis());
-    assertEquals(1322647200000L, vote.getFreshUntilMillis());
-    assertEquals(1322654400000L, vote.getValidUntilMillis());
-    assertEquals(300L, vote.getVoteSeconds());
-    assertEquals(300L, vote.getDistSeconds());
-    assertTrue(vote.getKnownFlags().contains("Running"));
-    assertEquals(30000, (int) vote.getConsensusParams().get(
-        "CircuitPriorityHalflifeMsec"));
-    assertEquals("Tor 0.2.1.29 (r8e9b25e6c7a2e70c)",
-        vote.getStatusEntry("00343A8024F70E214728F0C5AF7ACE0C1508F073").
-        getVersion());
-    assertEquals(3, vote.getDirKeyCertificateVersion());
-    assertEquals("80550987E1D626E3EBA5E5E75A458DE0626D088C",
-        vote.getIdentity());
-    assertEquals(1303882477000L, /* 2011-04-27 05:34:37 */
-        vote.getDirKeyPublishedMillis());
-    assertEquals(1335504877000L, /* 2012-04-27 05:34:37 */
-        vote.getDirKeyExpiresMillis());
-    assertEquals("-----BEGIN RSA PUBLIC KEY-----",
-        vote.getDirIdentityKey().split("\n")[0]);
-    assertEquals("-----BEGIN RSA PUBLIC KEY-----",
-        vote.getDirSigningKey().split("\n")[0]);
-    assertEquals("-----BEGIN ID SIGNATURE-----",
-        vote.getDirKeyCrosscert().split("\n")[0]);
-    assertEquals("-----BEGIN SIGNATURE-----",
-        vote.getDirKeyCertification().split("\n")[0]);
-    assertEquals(1, vote.getSignatures().size());
-    DirectorySignature signature = vote.getSignatures().get(0);
-    assertEquals("sha1", signature.getAlgorithm());
-    assertEquals("80550987E1D626E3EBA5E5E75A458DE0626D088C",
-        signature.getIdentity());
-    assertEquals("EEB9299D295C1C815E289FBF2F2BBEA5F52FDD19",
-        signature.getSigningKeyDigest());
-    assertEquals("-----BEGIN SIGNATURE-----\n"
-        + "iHEU3Iidya5RIrjyYgv8tlU0R+rF56/3/MmaaZi0a67e7ZkISfQ4dghScHxn"
-        + "F3Yh\nrXVaaoP07r6Ta+s0g1Zijm3lms50Nk/4tV2p8Y63c3F4Q3DAnK40Oi"
-        + "kfOIwEj+Ny\n+zBRQssP3hPhTPOj/A7o3mZZwtL6x1sxpeu/nME1l5E=\n"
-        + "-----END SIGNATURE-----\n", signature.getSignature());
-    assertTrue(vote.getUnrecognizedLines().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNoLine()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNewLine()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version 3\n");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNewLineSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version 3\n ");
-  }
-
-  @Test()
-  public void testNetworkStatusVersionPrefixLineAtChar()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "@vote\nnetwork-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionPrefixLine()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "directory-footer\nnetwork-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionPrefixLinePoundChar()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "#vote\nnetwork-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionNoSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionOneSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersion42()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version 42");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionFourtyTwo()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        "network-status-version FourtyTwo");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusNoLine() throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNetworkStatusVersionSpaceBefore()
-      throws DescriptorParseException {
-    VoteBuilder.createWithNetworkStatusVersionLine(
-        " network-status-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusSpaceBefore() throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine(" vote-status vote");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusNoSpace() throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine("vote-status");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusOneSpace() throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine("vote-status ");
-  }
-
-  @Test()
-  public void testVoteStatusVoteOneSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine("vote-status vote ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusConsensus() throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine("vote-status consensus");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVoteStatusTheMagicVoteStatus()
-      throws DescriptorParseException {
-    VoteBuilder.createWithVoteStatusLine(
-        "vote-status TheMagicVoteStatus");
-  }
-
-  @Test()
-  public void testConsensusMethodNoLine()
-      throws DescriptorParseException {
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithConsensusMethodsLine(null);
-    assertNull(vote.getConsensusMethods());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodNoSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithConsensusMethodsLine("consensus-methods");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodOneSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithConsensusMethodsLine("consensus-methods ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodEleven()
-      throws DescriptorParseException {
-    VoteBuilder.createWithConsensusMethodsLine(
-        "consensus-methods eleven");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodMinusOne()
-      throws DescriptorParseException {
-    VoteBuilder.createWithConsensusMethodsLine("consensus-methods -1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodNinePeriod()
-      throws DescriptorParseException {
-    VoteBuilder.createWithConsensusMethodsLine("consensus-methods "
-        + "999999999999999999999999999999999999999999999999999999999999");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testConsensusMethodTwoLines()
-      throws DescriptorParseException {
-    VoteBuilder.createWithConsensusMethodsLine(
-        "consensus-method 1\nconsensus-method 1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublishedNoLine() throws DescriptorParseException {
-    VoteBuilder.createWithPublishedLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterNoLine() throws DescriptorParseException {
-    VoteBuilder.createWithValidAfterLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterNoSpace() throws DescriptorParseException {
-    VoteBuilder.createWithValidAfterLine("valid-after");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterOneSpace() throws DescriptorParseException {
-    VoteBuilder.createWithValidAfterLine("valid-after ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterLongAgo() throws DescriptorParseException {
-    VoteBuilder.createWithValidAfterLine("valid-after long ago");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidAfterFeb30() throws DescriptorParseException {
-    VoteBuilder.createWithValidAfterLine(
-        "valid-after 2011-02-30 09:00:00");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFreshUntilNoLine() throws DescriptorParseException {
-    VoteBuilder.createWithFreshUntilLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFreshUntilAroundTen() throws DescriptorParseException {
-    VoteBuilder.createWithFreshUntilLine(
-        "fresh-until 2011-11-30 around ten");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testValidUntilTomorrowMorning()
-      throws DescriptorParseException {
-    VoteBuilder.createWithValidUntilLine(
-        "valid-until tomorrow morning");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayNoLine() throws DescriptorParseException {
-    VoteBuilder.createWithVotingDelayLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayNoSpace() throws DescriptorParseException {
-    VoteBuilder.createWithVotingDelayLine("voting-delay");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayOneSpace() throws DescriptorParseException {
-    VoteBuilder.createWithVotingDelayLine("voting-delay ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayTriple() throws DescriptorParseException {
-    VoteBuilder.createWithVotingDelayLine(
-        "voting-delay 300 300 300");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelaySingle() throws DescriptorParseException {
-    VoteBuilder.createWithVotingDelayLine("voting-delay 300");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testVotingDelayOneTwo() throws DescriptorParseException {
-    VoteBuilder.createWithVotingDelayLine("voting-delay one two");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testClientVersionsComma() throws DescriptorParseException {
-    VoteBuilder.createWithClientVersionsLine("client-versions ,");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testClientVersionsCommaVersion()
-      throws DescriptorParseException {
-    VoteBuilder.createWithClientVersionsLine(
-        "client-versions ,0.2.2.34");
-  }
-
-  @Test()
-  public void testPackageNone() throws DescriptorParseException {
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithPackageLines(null);
-    assertNull(vote.getPackageLines());
-  }
-
-  @Test()
-  public void testPackageOne() throws DescriptorParseException {
-    String packageLine = "package shouldbesecond 0 http digest=digest";
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithPackageLines(packageLine);
-    assertEquals(packageLine.substring("package ".length()),
-        vote.getPackageLines().get(0));
-  }
-
-  @Test()
-  public void testPackageTwo() throws DescriptorParseException {
-    List<String> packageLines = Arrays.asList(
-        "package shouldbesecond 0 http digest=digest",
-        "package outoforder 0 http digest=digest");
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithPackageLines(packageLines.get(0)
-        + "\n" + packageLines.get(1));
-    for (int i = 0; i < packageLines.size(); i++) {
-      assertEquals(packageLines.get(i).substring("package ".length()),
-          vote.getPackageLines().get(i));
-    }
-  }
-
-   @Test(expected = DescriptorParseException.class)
-   public void testPackageIncomplete() throws DescriptorParseException {
-     String packageLine = "package shouldbesecond 0 http";
-     ConsensusBuilder.createWithPackageLines(packageLine);
-   }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testKnownFlagsNoLine() throws DescriptorParseException {
-    VoteBuilder.createWithKnownFlagsLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testKnownFlagsNoSpace() throws DescriptorParseException {
-    VoteBuilder.createWithKnownFlagsLine("known-flags");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testKnownFlagsOneSpace() throws DescriptorParseException {
-    VoteBuilder.createWithKnownFlagsLine("known-flags ");
-  }
-
-  @Test()
-  public void testFlagThresholdsLine() throws DescriptorParseException {
-    VoteBuilder vb = new VoteBuilder();
-    RelayNetworkStatusVote vote =
-        new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-    assertEquals(693369L, vote.getStableUptime());
-    assertEquals(153249L, vote.getStableMtbf());
-    assertEquals(40960L, vote.getFastBandwidth());
-    assertEquals(94.669, vote.getGuardWfu(), 0.001);
-    assertEquals(691200L, vote.getGuardTk());
-    assertEquals(174080L, vote.getGuardBandwidthIncludingExits());
-    assertEquals(184320L, vote.getGuardBandwidthExcludingExits());
-    assertEquals(1, vote.getEnoughMtbfInfo());
-  }
-
-  @Test()
-  public void testFlagThresholdsNoLine() throws DescriptorParseException {
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithFlagThresholdsLine(null);
-    assertEquals(-1L, vote.getStableUptime());
-    assertEquals(-1L, vote.getStableMtbf());
-    assertEquals(-1L, vote.getFastBandwidth());
-    assertEquals(-1.0, vote.getGuardWfu(), 0.001);
-    assertEquals(-1L, vote.getGuardTk());
-    assertEquals(-1L, vote.getGuardBandwidthIncludingExits());
-    assertEquals(-1L, vote.getGuardBandwidthExcludingExits());
-    assertEquals(-1, vote.getEnoughMtbfInfo());
-  }
-
-  @Test()
-  public void testFlagThresholdsAllZeroes()
-      throws DescriptorParseException {
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithFlagThresholdsLine("flag-thresholds "
-            + "stable-uptime=0 stable-mtbf=0 fast-speed=0 guard-wfu=0.0% "
-            + "guard-tk=0 guard-bw-inc-exits=0 guard-bw-exc-exits=0 "
-            + "enough-mtbf=0");
-    assertEquals(0L, vote.getStableUptime());
-    assertEquals(0L, vote.getStableMtbf());
-    assertEquals(0L, vote.getFastBandwidth());
-    assertEquals(0.0, vote.getGuardWfu(), 0.001);
-    assertEquals(0L, vote.getGuardTk());
-    assertEquals(0L, vote.getGuardBandwidthIncludingExits());
-    assertEquals(0L, vote.getGuardBandwidthExcludingExits());
-    assertEquals(0, vote.getEnoughMtbfInfo());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFlagThresholdsNoSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithFlagThresholdsLine("flag-thresholds");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFlagThresholdsOneSpace()
-      throws DescriptorParseException {
-    VoteBuilder.createWithFlagThresholdsLine("flag-thresholds ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFlagThresholdDuplicate()
-      throws DescriptorParseException {
-    VoteBuilder vb = new VoteBuilder();
-    vb.flagThresholdsLine = vb.flagThresholdsLine + "\n"
-        + vb.flagThresholdsLine;
-    new RelayNetworkStatusVoteImpl(vb.buildVote(), true);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameMissing() throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source  "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameTooLong() throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source "
-        + "urrassssssssssssssssssssssssssssssssssssssssssssssss "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameIllegalCharacters()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urra$ "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test()
-  public void testFingerprintLowerCase() throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987e1d626e3eba5e5e75a458de0626d088c 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooShort() throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooLong() throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C8055 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintIllegalCharacters()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "ABCDEFGHIJKLM6E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + " 208.83.223.34 208.83.223.34 443 80");
-  }
-
-  @Test()
-  public void testHostname256()
-      throws DescriptorParseException {
-    /* This test doesn't fail, because we're not parsing the hostname. */
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 256.256.256.256 "
-        + "208.83.223.34 443 80");
-    assertEquals("256.256.256.256", vote.getHostname());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testHostnameMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C  208.83.223.34 443 "
-        + "80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddress256()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "256.256.256.256 443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddressMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34  443 "
-        + "80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirPortMinus443()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 -443 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirPortFourFourThree()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 four-four-three 80");
-  }
-
-  @Test()
-  public void testDirPort0() throws DescriptorParseException {
-    /* This test doesn't fail, because we're accepting DirPort 0, even
-     * though it doesn't make sense from Tor's view. */
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 0 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPortMissing() throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 ");
-  }
-
-  @Test()
-  public void testDirPortOrPortIdentical()
-      throws DescriptorParseException {
-    /* This test doesn't fail, even though identical OR and Dir port don't
-     * make much sense from Tor's view. */
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 80 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceLineMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSourceLineDuplicate()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSourceLine("dir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80\ndir-source urras "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C 208.83.223.34 "
-        + "208.83.223.34 443 80");
-  }
-
-  @Test()
-  public void testContactLineMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithContactLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testContactLineDuplicate()
-      throws DescriptorParseException {
-    VoteBuilder.createWithContactLine("contact 4096R/E012B42D Jacob "
-        + "Appelbaum <jacob@xxxxxxxxxxxxx>\ncontact 4096R/E012B42D Jacob "
-        + "Appelbaum <jacob@xxxxxxxxxxxxx>");
-  }
-
-  @Test()
-  public void testLegacyDirKeyLine() throws DescriptorParseException {
-    RelayNetworkStatusVote vote = VoteBuilder.createWithLegacyDirKeyLine(
-        "legacy-dir-key 81349FC1F2DBA2C2C11B45CB9706637D480AB913");
-    assertEquals("81349FC1F2DBA2C2C11B45CB9706637D480AB913",
-        vote.getLegacyDirKey());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testLegacyDirKeyLineNoId() throws DescriptorParseException {
-    VoteBuilder.createWithLegacyDirKeyLine("legacy-dir-key ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyCertificateVersionLineMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyCertificateVersionLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyCertificateVersionLineDuplicate()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyCertificateVersionLine(
-        "dir-key-certificate-version 3\ndir-key-certificate-version 3");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintLineMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithFingerprintLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintLineDuplicate()
-      throws DescriptorParseException {
-    VoteBuilder.createWithFingerprintLine("fingerprint "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C\nfingerprint "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintLineTooLong()
-      throws DescriptorParseException {
-    VoteBuilder.createWithFingerprintLine("fingerprint "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D088C8055");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintLineTooShort()
-      throws DescriptorParseException {
-    VoteBuilder.createWithFingerprintLine("fingerprint "
-        + "80550987E1D626E3EBA5E5E75A458DE0626D");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyPublished3011()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyPublishedLine("dir-key-published "
-        + "3011-04-27 05:34:37");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyPublishedRecentlyAtNoon()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyPublishedLine("dir-key-published "
-        + "recently 12:00:00");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyPublishedRecentlyNoTime()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyPublishedLine("dir-key-published "
-        + "recently");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyExpiresSoonAtNoon()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyExpiresLine("dir-key-expires "
-        + "soon 12:00:00");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyExpiresLineMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyExpiresLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyExpiresLineDuplicate()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyExpiresLine("dir-key-expires 2012-04-27 "
-        + "05:34:37\ndir-key-expires 2012-04-27 05:34:37");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirIdentityKeyLinesMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirIdentityKeyLines(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirSigningKeyLinesMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirSigningKeyLines(null);
-  }
-
-  @Test()
-  public void testDirKeyCrosscertLinesMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyCrosscertLines(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirKeyCertificationLinesMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirKeyCertificationLines(null);
-  }
-
-  @Test()
-  public void testDirectoryFooterLineMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirectoryFooterLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirectorySignaturesLinesMissing()
-      throws DescriptorParseException {
-    VoteBuilder.createWithDirectorySignatureLines(null);
-  }
-
-  @Test()
-  public void testDirectorySignaturesLinesTwoAlgorithms()
-      throws DescriptorParseException {
-    String identitySha256 = "32519E5CB7254AB5A94CC9925EC7676E53D5D52EEAB7"
-        + "914BD3ED751E537CAFCC";
-    String signingKeyDigestSha256 = "5A59D99C17831B9254422B6C5AA10CC59381"
-        + "6CAA5241E22ECAE8BBB4E8E9D1FC";
-    String signatureSha256 = "-----BEGIN SIGNATURE-----\n"
-        + "x57Alc424/zHS73SHokghGtNBVrBjtUz+gSL5w9AHGKUQcMyfw4Z9aDlKpTbFc"
-        + "5W\nnyIvFmM9C2OAH0S1+a647HHIxhE0zKf4+yKSwzqSyL6sbKQygVlJsRHNRr"
-        + "cFg8lp\nqBxEwvxQoA4xEDqnerR92pbK9l42nNLiKOcoReUqbbQ=\n"
-        + "-----END SIGNATURE-----";
-    String identitySha1 = "80550987E1D626E3EBA5E5E75A458DE0626D088C";
-    String signingKeyDigestSha1 =
-        "EEB9299D295C1C815E289FBF2F2BBEA5F52FDD19";
-    String signatureSha1 = "-----BEGIN SIGNATURE-----\n"
-        + "iHEU3Iidya5RIrjyYgv8tlU0R+rF56/3/MmaaZi0a67e7ZkISfQ4dghScHxnF3"
-        + "Yh\nrXVaaoP07r6Ta+s0g1Zijm3lms50Nk/4tV2p8Y63c3F4Q3DAnK40OikfOI"
-        + "wEj+Ny\n+zBRQssP3hPhTPOj/A7o3mZZwtL6x1sxpeu/nME1l5E=\n"
-        + "-----END SIGNATURE-----";
-    String signaturesLines = String.format(
-        "directory-signature sha256 %s %s\n%s\n"
-        + "directory-signature %s %s\n%s", identitySha256,
-        signingKeyDigestSha256, signatureSha256, identitySha1,
-        signingKeyDigestSha1, signatureSha1);
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithDirectorySignatureLines(signaturesLines);
-    assertEquals(2, vote.getSignatures().size());
-    DirectorySignature firstSignature = vote.getSignatures().get(0);
-    assertEquals("sha256", firstSignature.getAlgorithm());
-    assertEquals(identitySha256, firstSignature.getIdentity());
-    assertEquals(signingKeyDigestSha256,
-        firstSignature.getSigningKeyDigest());
-    assertEquals(signatureSha256 + "\n", firstSignature.getSignature());
-    DirectorySignature secondSignature = vote.getSignatures().get(1);
-    assertEquals("sha1", secondSignature.getAlgorithm());
-    assertEquals(identitySha1, secondSignature.getIdentity());
-    assertEquals(signingKeyDigestSha1,
-        secondSignature.getSigningKeyDigest());
-    assertEquals(signatureSha1 + "\n", secondSignature.getSignature());
-    assertEquals(signingKeyDigestSha1, vote.getSigningKeyDigest());
-  }
-
-  @Test()
-  public void testDirectorySignaturesLinesTwoAlgorithmsSameDigests()
-      throws DescriptorParseException {
-    String signaturesLines = "directory-signature 00 00\n"
-        + "-----BEGIN SIGNATURE-----\n00\n-----END SIGNATURE-----\n"
-        + "directory-signature sha256 00 00\n"
-        + "-----BEGIN SIGNATURE-----\n00\n-----END SIGNATURE-----";
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithDirectorySignatureLines(signaturesLines);
-    assertEquals(2, vote.getSignatures().size());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedHeaderLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    VoteBuilder.createWithUnrecognizedHeaderLine(unrecognizedLine, true);
-  }
-
-  @Test()
-  public void testUnrecognizedHeaderLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusVote vote = VoteBuilder.
-        createWithUnrecognizedHeaderLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, vote.getUnrecognizedLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedDirSourceLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    VoteBuilder.createWithUnrecognizedDirSourceLine(unrecognizedLine,
-        true);
-  }
-
-  @Test()
-  public void testUnrecognizedDirSourceLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusVote vote = VoteBuilder.
-        createWithUnrecognizedDirSourceLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, vote.getUnrecognizedLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedFooterLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    VoteBuilder.createWithUnrecognizedFooterLine(unrecognizedLine, true);
-  }
-
-  @Test()
-  public void testUnrecognizedFooterLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    RelayNetworkStatusVote vote = VoteBuilder.
-        createWithUnrecognizedFooterLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, vote.getUnrecognizedLines());
-  }
-
-  @Test()
-  public void testIdEd25519MasterKey()
-      throws DescriptorParseException {
-    String masterKey25519 = "8RH34kO07Pp+XYwzdoATVyCibIvmbslUjRkAm7J4IA8";
-    List<String> statusEntries = new ArrayList<>();
-    statusEntries.add("r PDrelay1 AAFJ5u9xAqrKlpDW6N0pMhJLlKs "
-        + "bgJiI/la3e9u0K7cQ5pMSXhigHI 2015-12-01 04:54:30 95.215.44.189 "
-        + "8080 0\n"
-        + "id ed25519 " + masterKey25519);
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithStatusEntries(statusEntries);
-    String fingerprint = vote.getStatusEntries().firstKey();
-    assertEquals(masterKey25519,
-        vote.getStatusEntry(fingerprint).getMasterKeyEd25519());
-  }
-
-  @Test()
-  public void testIdEd25519None()
-      throws DescriptorParseException {
-    List<String> statusEntries = new ArrayList<>();
-    statusEntries.add("r MathematicalApology AAPJIrV9nhfgTYQs0vsTghFaP2A "
-        + "uA7p0m68O8ILXsf3aLZUj0EvDNE 2015-12-01 18:01:49 172.99.69.177 "
-        + "443 9030\n"
-        + "id ed25519 none");
-    RelayNetworkStatusVote vote =
-        VoteBuilder.createWithStatusEntries(statusEntries);
-    String fingerprint = vote.getStatusEntries().firstKey();
-    assertEquals("none",
-        vote.getStatusEntry(fingerprint).getMasterKeyEd25519());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIdRsa1024None()
-      throws DescriptorParseException {
-    List<String> statusEntries = new ArrayList<>();
-    statusEntries.add("r MathematicalApology AAPJIrV9nhfgTYQs0vsTghFaP2A "
-        + "uA7p0m68O8ILXsf3aLZUj0EvDNE 2015-12-01 18:01:49 172.99.69.177 "
-        + "443 9030\n"
-        + "id rsa1024 none");
-    VoteBuilder.createWithStatusEntries(statusEntries);
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/ServerDescriptorImplTest.java b/test/org/torproject/descriptor/impl/ServerDescriptorImplTest.java
deleted file mode 100644
index cd3f1a5..0000000
--- a/test/org/torproject/descriptor/impl/ServerDescriptorImplTest.java
+++ /dev/null
@@ -1,1605 +0,0 @@
-/* Copyright 2012--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import org.torproject.descriptor.DescriptorParseException;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.SortedMap;
-
-import org.junit.Test;
-import org.torproject.descriptor.BandwidthHistory;
-import org.torproject.descriptor.ServerDescriptor;
-
-/* Test parsing of relay server descriptors. */
-public class ServerDescriptorImplTest {
-
-  /* Helper class to build a descriptor based on default data and
-   * modifications requested by test methods. */
-  private static class DescriptorBuilder {
-    private String routerLine = "router saberrider2008 94.134.192.243 "
-        + "9001 0 0";
-    private static ServerDescriptor createWithRouterLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.routerLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String bandwidthLine = "bandwidth 51200 51200 53470";
-    private static ServerDescriptor createWithBandwidthLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.bandwidthLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String platformLine = "platform Tor 0.2.2.35 "
-        + "(git-b04388f9e7546a9f) on Linux i686";
-    private static ServerDescriptor createWithPlatformLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.platformLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String publishedLine = "published 2012-01-01 04:03:19";
-    private static ServerDescriptor createWithPublishedLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.publishedLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String fingerprintLine = "opt fingerprint D873 3048 FC8E "
-        + "C910 2466 AD8F 3098 622B F1BF 71FD";
-    private static ServerDescriptor createWithFingerprintLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.fingerprintLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String hibernatingLine = null;
-    private static ServerDescriptor createWithHibernatingLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.hibernatingLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String uptimeLine = "uptime 48";
-    private static ServerDescriptor createWithUptimeLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.uptimeLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String onionKeyLines = "onion-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ1U4V9SeiKooSo5Bp"
-        + "PL\no3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3olIynCI4QryfCE"
-        + "uC3cTF\n9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKFfacOkpAg"
-        + "MBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----";
-    private static ServerDescriptor createWithOnionKeyLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.onionKeyLines = lines;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String signingKeyLines = "signing-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIGJAoGBALMm3r3QDh482Ewe6Ub9wvRIfmEkoNX6q5cEAtQRNHSDcNx41gjELb"
-        + "cl\nEniVMParBYACKfOxkS+mTTnIRDKVNEJTsDOwryNrc4X9JnPc/nn6ymYPiN"
-        + "DhUROG\n8URDIhQoixcUeyyrVB8sxliSstKimulGnB7xpjYOlO8JKaHLNL4TAg"
-        + "MBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----";
-    private static ServerDescriptor createWithSigningKeyLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.signingKeyLines = lines;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String onionKeyCrosscertLines = null;
-    private static ServerDescriptor createWithOnionKeyCrosscertLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.onionKeyCrosscertLines = lines;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String ntorOnionKeyCrosscertLines = null;
-    private static ServerDescriptor createWithNtorOnionKeyCrosscertLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.ntorOnionKeyCrosscertLines = lines;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String exitPolicyLines = "reject *:*";
-    private static ServerDescriptor createWithExitPolicyLines(
-        String lines) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.exitPolicyLines = lines;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String contactLine = "contact Random Person <nobody AT "
-        + "example dot com>";
-    private static ServerDescriptor createWithContactLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.contactLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String familyLine = null;
-    private static ServerDescriptor createWithFamilyLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.familyLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String readHistoryLine = null;
-    private static ServerDescriptor createWithReadHistoryLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.readHistoryLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String writeHistoryLine = null;
-    private static ServerDescriptor createWithWriteHistoryLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.writeHistoryLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String eventdnsLine = null;
-    private static ServerDescriptor createWithEventdnsLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.eventdnsLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String cachesExtraInfoLine = null;
-    private static ServerDescriptor createWithCachesExtraInfoLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.cachesExtraInfoLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String extraInfoDigestLine = "opt extra-info-digest "
-        + "1469D1550738A25B1E7B47CDDBCD7B2899F51B74";
-    private static ServerDescriptor createWithExtraInfoDigestLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.extraInfoDigestLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String hiddenServiceDirLine = "opt hidden-service-dir";
-    private static ServerDescriptor createWithHiddenServiceDirLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.hiddenServiceDirLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String protocolsLine = "opt protocols Link 1 2 Circuit 1";
-    private static ServerDescriptor createWithProtocolsLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.protocolsLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String allowSingleHopExitsLine = null;
-    private static ServerDescriptor
-        createWithAllowSingleHopExitsLine(String line)
-        throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.allowSingleHopExitsLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String ipv6PolicyLine = null;
-    private static ServerDescriptor createWithIpv6PolicyLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.ipv6PolicyLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String ntorOnionKeyLine = null;
-    private static ServerDescriptor createWithNtorOnionKeyLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.ntorOnionKeyLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String tunnelledDirServerLine = null;
-    private static ServerDescriptor createWithTunnelledDirServerLine(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.tunnelledDirServerLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String routerSignatureLines = "router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
-        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
-        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
-        + "-----END SIGNATURE-----";
-    private static ServerDescriptor createWithRouterSignatureLines(
-        String line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.routerSignatureLines = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private String unrecognizedLine = null;
-    private static ServerDescriptor createWithUnrecognizedLine(
-        String line, boolean failUnrecognizedDescriptorLines)
-        throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.unrecognizedLine = line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(),
-          failUnrecognizedDescriptorLines);
-    }
-    private byte[] nonAsciiLineBytes = null;
-    private static ServerDescriptor createWithNonAsciiLineBytes(
-        byte[] lineBytes, boolean failUnrecognizedDescriptorLines)
-            throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.nonAsciiLineBytes = lineBytes;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(),
-          failUnrecognizedDescriptorLines);
-    }
-    private String identityEd25519Lines = null,
-        masterKeyEd25519Line = null, routerSigEd25519Line = null;
-    private static ServerDescriptor createWithEd25519Lines(
-        String identityEd25519Lines, String masterKeyEd25519Line,
-        String routerSigEd25519Line) throws DescriptorParseException {
-      DescriptorBuilder db = new DescriptorBuilder();
-      db.identityEd25519Lines = identityEd25519Lines;
-      db.masterKeyEd25519Line = masterKeyEd25519Line;
-      db.routerSigEd25519Line = routerSigEd25519Line;
-      return new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    }
-    private byte[] buildDescriptor() {
-      StringBuilder sb = new StringBuilder();
-      if (this.routerLine != null) {
-        sb.append(this.routerLine).append("\n");
-      }
-      if (this.identityEd25519Lines != null) {
-        sb.append(this.identityEd25519Lines).append("\n");
-      }
-      if (this.masterKeyEd25519Line != null) {
-        sb.append(this.masterKeyEd25519Line).append("\n");
-      }
-      if (this.bandwidthLine != null) {
-        sb.append(this.bandwidthLine).append("\n");
-      }
-      if (this.platformLine != null) {
-        sb.append(this.platformLine).append("\n");
-      }
-      if (this.publishedLine != null) {
-        sb.append(this.publishedLine).append("\n");
-      }
-      if (this.fingerprintLine != null) {
-        sb.append(this.fingerprintLine).append("\n");
-      }
-      if (this.hibernatingLine != null) {
-        sb.append(this.hibernatingLine).append("\n");
-      }
-      if (this.uptimeLine != null) {
-        sb.append(this.uptimeLine).append("\n");
-      }
-      if (this.onionKeyLines != null) {
-        sb.append(this.onionKeyLines).append("\n");
-      }
-      if (this.signingKeyLines != null) {
-        sb.append(this.signingKeyLines).append("\n");
-      }
-      if (this.onionKeyCrosscertLines != null) {
-        sb.append(this.onionKeyCrosscertLines).append("\n");
-      }
-      if (this.ntorOnionKeyCrosscertLines != null) {
-        sb.append(this.ntorOnionKeyCrosscertLines).append("\n");
-      }
-      if (this.exitPolicyLines != null) {
-        sb.append(this.exitPolicyLines).append("\n");
-      }
-      if (this.contactLine != null) {
-        sb.append(this.contactLine).append("\n");
-      }
-      if (this.familyLine != null) {
-        sb.append(this.familyLine).append("\n");
-      }
-      if (this.readHistoryLine != null) {
-        sb.append(this.readHistoryLine).append("\n");
-      }
-      if (this.writeHistoryLine != null) {
-        sb.append(this.writeHistoryLine).append("\n");
-      }
-      if (this.eventdnsLine != null) {
-        sb.append(this.eventdnsLine).append("\n");
-      }
-      if (this.cachesExtraInfoLine != null) {
-        sb.append(this.cachesExtraInfoLine).append("\n");
-      }
-      if (this.extraInfoDigestLine != null) {
-        sb.append(this.extraInfoDigestLine).append("\n");
-      }
-      if (this.hiddenServiceDirLine != null) {
-        sb.append(this.hiddenServiceDirLine).append("\n");
-      }
-      if (this.protocolsLine != null) {
-        sb.append(this.protocolsLine).append("\n");
-      }
-      if (this.allowSingleHopExitsLine != null) {
-        sb.append(this.allowSingleHopExitsLine).append("\n");
-      }
-      if (this.ipv6PolicyLine != null) {
-        sb.append(this.ipv6PolicyLine).append("\n");
-      }
-      if (this.ntorOnionKeyLine != null) {
-        sb.append(this.ntorOnionKeyLine).append("\n");
-      }
-      if (this.tunnelledDirServerLine != null) {
-        sb.append(this.tunnelledDirServerLine).append("\n");
-      }
-      if (this.unrecognizedLine != null) {
-        sb.append(this.unrecognizedLine).append("\n");
-      }
-      if (this.nonAsciiLineBytes != null) {
-        try {
-          ByteArrayOutputStream baos = new ByteArrayOutputStream();
-          baos.write(sb.toString().getBytes());
-          baos.write(this.nonAsciiLineBytes);
-          baos.write("\n".getBytes());
-          if (this.routerSignatureLines != null) {
-            baos.write(this.routerSignatureLines.getBytes());
-          }
-          return baos.toByteArray();
-        } catch (IOException e) {
-          return null;
-        }
-      }
-      if (this.routerSigEd25519Line != null) {
-        sb.append(this.routerSigEd25519Line).append("\n");
-      }
-      if (this.routerSignatureLines != null) {
-        sb.append(this.routerSignatureLines).append("\n");
-      }
-      return sb.toString().getBytes();
-    }
-  }
-
-  @Test()
-  public void testSampleDescriptor() throws DescriptorParseException {
-    DescriptorBuilder db = new DescriptorBuilder();
-    ServerDescriptor descriptor =
-        new RelayServerDescriptorImpl(db.buildDescriptor(), true);
-    assertEquals("saberrider2008", descriptor.getNickname());
-    assertEquals("94.134.192.243", descriptor.getAddress());
-    assertEquals(9001, (int) descriptor.getOrPort());
-    assertEquals(0, (int) descriptor.getSocksPort());
-    assertEquals(0, (int) descriptor.getDirPort());
-    assertEquals("Tor 0.2.2.35 (git-b04388f9e7546a9f) on Linux i686",
-        descriptor.getPlatform());
-    assertEquals(Arrays.asList(new Integer[] {1, 2}),
-        descriptor.getLinkProtocolVersions());
-    assertEquals(Arrays.asList(new Integer[] {1}),
-        descriptor.getCircuitProtocolVersions());
-    assertEquals(1325390599000L, descriptor.getPublishedMillis());
-    assertEquals("D8733048FC8EC9102466AD8F3098622BF1BF71FD",
-        descriptor.getFingerprint());
-    assertEquals(48, descriptor.getUptime().longValue());
-    assertEquals(51200, (int) descriptor.getBandwidthRate());
-    assertEquals(51200, (int) descriptor.getBandwidthBurst());
-    assertEquals(53470, (int) descriptor.getBandwidthObserved());
-    assertEquals("1469D1550738A25B1E7B47CDDBCD7B2899F51B74",
-        descriptor.getExtraInfoDigest());
-    assertEquals(Arrays.asList(new Integer[] {2}),
-        descriptor.getHiddenServiceDirVersions());
-    assertEquals("Random Person <nobody AT example dot com>",
-        descriptor.getContact());
-    assertEquals(Arrays.asList(new String[] {"reject *:*"}),
-        descriptor.getExitPolicyLines());
-    assertFalse(descriptor.isHibernating());
-    assertNull(descriptor.getFamilyEntries());
-    assertNull(descriptor.getReadHistory());
-    assertNull(descriptor.getWriteHistory());
-    assertFalse(descriptor.getUsesEnhancedDnsLogic());
-    assertFalse(descriptor.getCachesExtraInfo());
-    assertFalse(descriptor.getAllowSingleHopExits());
-    assertTrue(descriptor.getUnrecognizedLines().isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testRouterLineMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine(null);
-  }
-
-  @Test()
-  public void testRouterOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithRouterLine("opt router saberrider2008 "
-        + "94.134.192.243 9001 0 0");
-    assertEquals("saberrider2008", descriptor.getNickname());
-    assertEquals("94.134.192.243", descriptor.getAddress());
-    assertEquals(9001, (int) descriptor.getOrPort());
-    assertEquals(0, (int) descriptor.getSocksPort());
-    assertEquals(0, (int) descriptor.getDirPort());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testRouterLinePrecedingHibernatingLine()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("hibernating 1\nrouter "
-        + "saberrider2008 94.134.192.243 9001 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router  94.134.192.243 9001 "
-        + "0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameInvalidChar() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router $aberrider2008 "
-        + "94.134.192.243 9001 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNicknameTooLong() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router "
-        + "saberrider2008ReallyLongNickname 94.134.192.243 9001 0 0");
-  }
-
-  @Test()
-  public void testNicknameTwoSpaces() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithRouterLine("router saberrider2008  "
-        + "94.134.192.243 9001 0 0");
-    assertEquals("saberrider2008", descriptor.getNickname());
-    assertEquals("94.134.192.243", descriptor.getAddress());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddress24() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "94.134.192/24 9001 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddress294() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "294.134.192.243 9001 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAddressMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008  9001 "
-        + "0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPort99001() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "94.134.192.243 99001 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPortMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "94.134.192.243  0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPortOne() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "94.134.192.243 one 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOrPortNewline() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "94.134.192.243 0\n 0 0");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testDirPortMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterLine("router saberrider2008 "
-        + "94.134.192.243 9001 0 ");
-  }
-
-  @Test()
-  public void testPlatformMissing() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithPlatformLine(null);
-    assertNull(descriptor.getPlatform());
-  }
-
-  @Test()
-  public void testPlatformOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithPlatformLine("opt platform Tor 0.2.2.35 "
-        + "(git-b04388f9e7546a9f) on Linux i686");
-    assertEquals("Tor 0.2.2.35 (git-b04388f9e7546a9f) on Linux i686",
-        descriptor.getPlatform());
-  }
-
-  @Test()
-  public void testPlatformNoSpace() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithPlatformLine("platform");
-    assertEquals("", descriptor.getPlatform());
-  }
-
-  @Test()
-  public void testPlatformSpace() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithPlatformLine("platform ");
-    assertEquals("", descriptor.getPlatform());
-  }
-
-  @Test()
-  public void testProtocolsNoOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithProtocolsLine("protocols Link 1 2 Circuit 1");
-    assertEquals(Arrays.asList(new Integer[] {1, 2}),
-        descriptor.getLinkProtocolVersions());
-    assertEquals(Arrays.asList(new Integer[] {1}),
-        descriptor.getCircuitProtocolVersions());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testProtocolsAB() throws DescriptorParseException {
-    DescriptorBuilder.createWithProtocolsLine("opt protocols Link A B "
-        + "Circuit 1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testProtocolsNoCircuitVersions()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithProtocolsLine("opt protocols Link 1 2");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublishedMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithPublishedLine(null);
-  }
-
-  @Test()
-  public void testPublishedOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithPublishedLine("opt published 2012-01-01 04:03:19");
-    assertEquals(1325390599000L, descriptor.getPublishedMillis());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublished2039() throws DescriptorParseException {
-    DescriptorBuilder.createWithPublishedLine("published 2039-01-01 "
-        + "04:03:19");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublished1912() throws DescriptorParseException {
-    DescriptorBuilder.createWithPublishedLine("published 1912-01-01 "
-        + "04:03:19");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublishedFeb31() throws DescriptorParseException {
-    DescriptorBuilder.createWithPublishedLine("published 2012-02-31 "
-        + "04:03:19");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testPublishedNoTime() throws DescriptorParseException {
-    DescriptorBuilder.createWithPublishedLine("published 2012-01-01");
-  }
-
-  @Test()
-  public void testPublishedMillis() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithPublishedLine("opt published 2012-01-01 04:03:19.123");
-    assertEquals(1325390599000L, descriptor.getPublishedMillis());
-  }
-
-  @Test()
-  public void testFingerprintNoOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithFingerprintLine("fingerprint D873 3048 FC8E C910 2466 "
-            + "AD8F 3098 622B F1BF 71FD");
-    assertEquals("D8733048FC8EC9102466AD8F3098622BF1BF71FD",
-        descriptor.getFingerprint());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintG() throws DescriptorParseException {
-    DescriptorBuilder.createWithFingerprintLine("opt fingerprint G873 "
-        + "3048 FC8E C910 2466 AD8F 3098 622B F1BF 71FD");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooShort() throws DescriptorParseException {
-    DescriptorBuilder.createWithFingerprintLine("opt fingerprint D873 "
-        + "3048 FC8E C910 2466 AD8F 3098 622B F1BF");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintTooLong() throws DescriptorParseException {
-    DescriptorBuilder.createWithFingerprintLine("opt fingerprint D873 "
-        + "3048 FC8E C910 2466 AD8F 3098 622B F1BF 71FD D873");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFingerprintNoSpaces() throws DescriptorParseException {
-    DescriptorBuilder.createWithFingerprintLine("opt fingerprint "
-        + "D8733048FC8EC9102466AD8F3098622BF1BF71FD");
-  }
-
-  @Test()
-  public void testUptimeMissing() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithUptimeLine(null);
-    assertNull(descriptor.getUptime());
-  }
-
-  @Test()
-  public void testUptimeOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithUptimeLine("opt uptime 48");
-    assertEquals(48, descriptor.getUptime().longValue());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUptimeFourtyEight() throws DescriptorParseException {
-    DescriptorBuilder.createWithUptimeLine("uptime fourty-eight");
-  }
-
-  @Test()
-  public void testUptimeMinusOne() throws DescriptorParseException {
-    DescriptorBuilder.createWithUptimeLine("uptime -1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUptimeSpace() throws DescriptorParseException {
-    DescriptorBuilder.createWithUptimeLine("uptime ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUptimeNoSpace() throws DescriptorParseException {
-    DescriptorBuilder.createWithUptimeLine("uptime");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUptimeFourEight() throws DescriptorParseException {
-    DescriptorBuilder.createWithUptimeLine("uptime 4 8");
-  }
-
-  @Test()
-  public void testBandwidthOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithBandwidthLine("opt bandwidth 51200 51200 53470");
-    assertEquals(51200, (int) descriptor.getBandwidthRate());
-    assertEquals(51200, (int) descriptor.getBandwidthBurst());
-    assertEquals(53470, (int) descriptor.getBandwidthObserved());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBandwidthMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithBandwidthLine(null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBandwidthOneValue() throws DescriptorParseException {
-    DescriptorBuilder.createWithBandwidthLine("bandwidth 51200");
-  }
-
-  @Test()
-  public void testBandwidthTwoValues() throws DescriptorParseException {
-    /* This is allowed, because Tor versions 0.0.8 and older only wrote
-     * bandwidth lines with rate and burst values, but no observed
-     * value. */
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithBandwidthLine("bandwidth 51200 51200");
-    assertEquals(51200, (int) descriptor.getBandwidthRate());
-    assertEquals(51200, (int) descriptor.getBandwidthBurst());
-    assertEquals(-1, (int) descriptor.getBandwidthObserved());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBandwidthFourValues() throws DescriptorParseException {
-    DescriptorBuilder.createWithBandwidthLine("bandwidth 51200 51200 "
-        + "53470 53470");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testBandwidthMinusOneTwoThree()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithBandwidthLine("bandwidth -1 -2 -3");
-  }
-
-  @Test()
-  public void testExtraInfoDigestNoOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExtraInfoDigestLine("extra-info-digest "
-        + "1469D1550738A25B1E7B47CDDBCD7B2899F51B74");
-    assertEquals("1469D1550738A25B1E7B47CDDBCD7B2899F51B74",
-        descriptor.getExtraInfoDigest());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExtraInfoDigestNoSpace()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoDigestLine("opt "
-        + "extra-info-digest");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExtraInfoDigestTooShort()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoDigestLine("opt "
-        + "extra-info-digest 1469D1550738A25B1E7B47CDDBCD7B2899F5");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExtraInfoDigestTooLong()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithExtraInfoDigestLine("opt "
-        + "extra-info-digest "
-        + "1469D1550738A25B1E7B47CDDBCD7B2899F51B741469");
-  }
-
-  @Test()
-  public void testExtraInfoDigestMissing()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExtraInfoDigestLine(null);
-    assertNull(descriptor.getExtraInfoDigest());
-  }
-
-  @Test()
-  public void testExtraInfoDigestAdditionalDigest()
-      throws DescriptorParseException {
-    String extraInfoDigest = "0879DB7B765218D7B3AE7557669D20307BB21CAA";
-    String additionalExtraInfoDigest =
-        "V609l+N6ActBveebfNbH5lQ6wHDNstDkFgyqEhBHwtA";
-    String extraInfoDigestLine = String.format("extra-info-digest %s %s",
-        extraInfoDigest, additionalExtraInfoDigest);
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExtraInfoDigestLine(extraInfoDigestLine);
-    assertEquals(extraInfoDigest, descriptor.getExtraInfoDigest());
-  }
-
-  @Test()
-  public void testOnionKeyOpt() throws DescriptorParseException {
-    DescriptorBuilder.createWithOnionKeyLines("opt onion-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ1U4V9SeiKooSo5Bp"
-        + "PL\no3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3olIynCI4QryfCE"
-        + "uC3cTF\n9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKFfacOkpAg"
-        + "MBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----");
-  }
-
-  @Test()
-  public void testSigningKeyOpt() throws DescriptorParseException {
-    DescriptorBuilder.createWithSigningKeyLines("opt signing-key\n"
-        + "-----BEGIN RSA PUBLIC KEY-----\n"
-        + "MIGJAoGBALMm3r3QDh482Ewe6Ub9wvRIfmEkoNX6q5cEAtQRNHSDcNx41gjELb"
-        + "cl\nEniVMParBYACKfOxkS+mTTnIRDKVNEJTsDOwryNrc4X9JnPc/nn6ymYPiN"
-        + "DhUROG\n8URDIhQoixcUeyyrVB8sxliSstKimulGnB7xpjYOlO8JKaHLNL4TAg"
-        + "MBAAE=\n"
-        + "-----END RSA PUBLIC KEY-----");
-  }
-
-  @Test()
-  public void testHiddenServiceDirMissing()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithHiddenServiceDirLine(null);
-    assertNull(descriptor.getHiddenServiceDirVersions());
-  }
-
-  @Test()
-  public void testHiddenServiceDirNoOpt()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithHiddenServiceDirLine("hidden-service-dir");
-    assertEquals(Arrays.asList(new Integer[] {2}),
-        descriptor.getHiddenServiceDirVersions());
-  }
-
-  @Test()
-  public void testHiddenServiceDirVersions2And3()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithHiddenServiceDirLine("hidden-service-dir 2 3");
-    assertEquals(Arrays.asList(new Integer[] {2, 3}),
-        descriptor.getHiddenServiceDirVersions());
-  }
-
-  @Test()
-  public void testContactMissing() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithContactLine(null);
-    assertNull(descriptor.getContact());
-  }
-
-  @Test()
-  public void testContactOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithContactLine("opt contact Random Person");
-    assertEquals("Random Person", descriptor.getContact());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testContactDuplicate() throws DescriptorParseException {
-    DescriptorBuilder.createWithContactLine("contact Random "
-        + "Person\ncontact Random Person");
-  }
-
-  @Test()
-  public void testContactNoSpace() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithContactLine("contact");
-    assertEquals("", descriptor.getContact());
-  }
-
-  @Test()
-  public void testContactCarriageReturn()
-      throws DescriptorParseException {
-    String contactString = "Random "
-        + "Person -----BEGIN PGP PUBLIC KEY BLOCK-----\r"
-        + "Version: GnuPG v1 dot 4 dot 7 (Darwin)\r\r"
-        + "mQGiBEbb0rcRBADqBiUXsmtpJifh74irNnkHbhKMj8O4TqenaZYhdjLWouZsZd"
-        + "07\rmTQoP40G4zqOrVEOOcXpdSiRnHWJYfgTnkibNZrOZEZLn3H1ywpovEgESm"
-        + "oGEdAX\roid3XuIYRpRnqoafbFg9sg+OofX/mGrO+5ACfagQ9rlfx2oxCWijYw"
-        + "pYFRk3NhCY=\r=Xaw3\r-----END PGP PUBLIC KEY BLOCK-----";
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithContactLine("contact " + contactString);
-    assertEquals(contactString, descriptor.getContact());
-  }
-
-  @Test()
-  public void testExitPolicyRejectAllAcceptAll()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExitPolicyLines("reject *:*\naccept *:*");
-    assertEquals(Arrays.asList(new String[] {"reject *:*", "accept *:*"}),
-        descriptor.getExitPolicyLines());
-  }
-
-  @Test()
-  public void testExitPolicyOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExitPolicyLines("opt reject *:*");
-    assertEquals(Arrays.asList(new String[] {"reject *:*"}),
-        descriptor.getExitPolicyLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitPolicyNoPort() throws DescriptorParseException {
-    DescriptorBuilder.createWithExitPolicyLines("reject *");
-  }
-
-  @Test()
-  public void testExitPolicyAccept80RejectAll()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExitPolicyLines("accept *:80\nreject *:*");
-    assertEquals(Arrays.asList(new String[] {"accept *:80",
-        "reject *:*"}), descriptor.getExitPolicyLines());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitPolicyReject321() throws DescriptorParseException {
-    DescriptorBuilder.createWithExitPolicyLines("reject "
-        + "123.123.123.321:80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitPolicyRejectPort66666()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithExitPolicyLines("reject *:66666");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitPolicyProjectAll() throws DescriptorParseException {
-    DescriptorBuilder.createWithExitPolicyLines("project *:*");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testExitPolicyMissing() throws DescriptorParseException {
-    DescriptorBuilder.createWithExitPolicyLines(null);
-  }
-
-  @Test()
-  public void testExitPolicyMaskTypes() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithExitPolicyLines("reject 192.168.0.0/16:*\n"
-        + "reject 94.134.192.243/255.255.255.0:*");
-    assertEquals(Arrays.asList(new String[] { "reject 192.168.0.0/16:*",
-        "reject 94.134.192.243/255.255.255.0:*"}),
-        descriptor.getExitPolicyLines());
-  }
-
-  @Test()
-  public void testRouterSignatureOpt()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterSignatureLines("opt "
-        + "router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "crypto lines are ignored anyway\n"
-        + "-----END SIGNATURE-----");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testRouterSignatureNotLastLine()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithRouterSignatureLines("router-signature\n"
-        + "-----BEGIN SIGNATURE-----\n"
-        + "o4j+kH8UQfjBwepUnr99v0ebN8RpzHJ/lqYsTojXHy9kMr1RNI9IDeSzA7PSqT"
-        + "uV\n4PL8QsGtlfwthtIoZpB2srZeyN/mcpA9fa1JXUrt/UN9K/+32Cyaad7h0n"
-        + "HE6Xfb\njqpXDpnBpvk4zjmzjjKYnIsUWTnADmu0fo3xTRqXi7g=\n"
-        + "-----END SIGNATURE-----\ncontact me");
-  }
-
-  @Test()
-  public void testHibernatingOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithHibernatingLine("opt hibernating 1");
-    assertTrue(descriptor.isHibernating());
-  }
-
-  @Test()
-  public void testHibernatingFalse() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithHibernatingLine("hibernating 0");
-    assertFalse(descriptor.isHibernating());
-  }
-
-  @Test()
-  public void testHibernatingTrue() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithHibernatingLine("hibernating 1");
-    assertTrue(descriptor.isHibernating());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testHibernatingYep() throws DescriptorParseException {
-    DescriptorBuilder.createWithHibernatingLine("hibernating yep");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testHibernatingNoSpace() throws DescriptorParseException {
-    DescriptorBuilder.createWithHibernatingLine("hibernating");
-  }
-
-  @Test()
-  public void testFamilyOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithFamilyLine("opt family saberrider2008");
-    assertEquals(Arrays.asList(new String[] {"saberrider2008"}),
-        descriptor.getFamilyEntries());
-  }
-
-  @Test()
-  public void testFamilyFingerprint() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithFamilyLine("family "
-        + "$D8733048FC8EC9102466AD8F3098622BF1BF71FD");
-    assertEquals(Arrays.asList(new String[] {
-        "$D8733048FC8EC9102466AD8F3098622BF1BF71FD"}),
-        descriptor.getFamilyEntries());
-  }
-
-  @Test()
-  public void testFamilyNickname() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithFamilyLine("family saberrider2008");
-    assertEquals(Arrays.asList(new String[] {"saberrider2008"}),
-        descriptor.getFamilyEntries());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFamilyDuplicate() throws DescriptorParseException {
-    DescriptorBuilder.createWithFamilyLine("family "
-        + "saberrider2008\nfamily saberrider2008");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFamilyNicknamePrefix() throws DescriptorParseException {
-    DescriptorBuilder.createWithFamilyLine("family $saberrider2008");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testFamilyFingerprintNoPrefix()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithFamilyLine("family "
-        + "D8733048FC8EC9102466AD8F3098622BF1BF71FD");
-  }
-
-  @Test()
-  public void testFamilyFingerprintNicknameNamed()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithFamilyLine("family "
-        + "$D8733048FC8EC9102466AD8F3098622BF1BF71FD=saberrider2008");
-    assertEquals(Arrays.asList(new String[]
-        { "$D8733048FC8EC9102466AD8F3098622BF1BF71FD=saberrider2008" }),
-        descriptor.getFamilyEntries());
-  }
-
-  @Test()
-  public void testFamilyFingerprintNicknameUnnamed()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithFamilyLine("family "
-        + "$D8733048FC8EC9102466AD8F3098622BF1BF71FD~saberrider2008");
-    assertEquals(Arrays.asList(new String[]
-        { "$D8733048FC8EC9102466AD8F3098622BF1BF71FD~saberrider2008" }),
-        descriptor.getFamilyEntries());
-  }
-
-  @Test()
-  public void testWriteHistory() throws DescriptorParseException {
-    String writeHistoryLine = "write-history 2012-01-01 03:51:44 (900 s) "
-        + "4345856,261120,7591936,1748992";
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithWriteHistoryLine(writeHistoryLine);
-    assertNotNull(descriptor.getWriteHistory());
-    BandwidthHistory parsedWriteHistory = descriptor.getWriteHistory();
-    assertEquals(writeHistoryLine, parsedWriteHistory.getLine());
-    assertEquals(1325389904000L, (long) parsedWriteHistory.
-        getHistoryEndMillis());
-    assertEquals(900L, (long) parsedWriteHistory.getIntervalLength());
-    SortedMap<Long, Long> bandwidthValues = parsedWriteHistory.
-        getBandwidthValues();
-    assertEquals(4345856L, (long) bandwidthValues.remove(1325387204000L));
-    assertEquals(261120L, (long) bandwidthValues.remove(1325388104000L));
-    assertEquals(7591936L, (long) bandwidthValues.remove(1325389004000L));
-    assertEquals(1748992L, (long) bandwidthValues.remove(1325389904000L));
-    assertTrue(bandwidthValues.isEmpty());
-  }
-
-  @Test()
-  public void testWriteHistoryOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithWriteHistoryLine("opt write-history 2012-01-01 "
-        + "03:51:44 (900 s) 4345856,261120,7591936,1748992");
-    assertNotNull(descriptor.getWriteHistory());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistory3012() throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "3012-01-01 03:51:44 (900 s) 4345856,261120,7591936,1748992");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryNoSeconds()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-01-01 03:51 (900 s) 4345856,261120,7591936,1748992");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryNoParathenses()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-01-01 03:51:44 900 s 4345856,261120,7591936,1748992");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryNoSpaceSeconds()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-01-01 03:51:44 (900s) 4345856,261120,7591936,1748992");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryTrailingComma()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-01-01 03:51:44 (900 s) 4345856,261120,7591936,");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryOneTwoThree()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-01-01 03:51:44 (900 s) one,two,three");
-  }
-
-  @Test()
-  public void testWriteHistoryNoValuesSpace()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithWriteHistoryLine("write-history 2012-01-01 03:51:44 "
-        + "(900 s) ");
-    assertEquals(900, (long) descriptor.getWriteHistory().
-        getIntervalLength());
-    assertTrue(descriptor.getWriteHistory().getBandwidthValues().
-        isEmpty());
-  }
-
-  @Test()
-  public void testWriteHistoryNoValuesNoSpace()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithWriteHistoryLine("write-history 2012-01-01 03:51:44 "
-        + "(900 s)");
-    assertEquals(900, (long) descriptor.getWriteHistory().
-        getIntervalLength());
-    assertTrue(descriptor.getWriteHistory().getBandwidthValues().
-        isEmpty());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryNoS() throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine(
-        "write-history 2012-01-01 03:51:44 (900 ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testWriteHistoryTrailingNumber()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithWriteHistoryLine("write-history "
-        + "2012-01-01 03:51:44 (900 s) 4345856 1");
-  }
-
-  @Test()
-  public void testWriteHistory1800Seconds()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithWriteHistoryLine("write-history 2012-01-01 03:51:44 "
-        + "(1800 s) 4345856");
-    assertEquals(1800L, (long) descriptor.getWriteHistory().
-        getIntervalLength());
-  }
-
-  @Test()
-  public void testReadHistory() throws DescriptorParseException {
-    String readHistoryLine = "read-history 2012-01-01 03:51:44 (900 s) "
-        + "4268032,139264,7797760,1415168";
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithReadHistoryLine(readHistoryLine);
-    assertNotNull(descriptor.getReadHistory());
-    BandwidthHistory parsedReadHistory = descriptor.getReadHistory();
-    assertEquals(readHistoryLine, parsedReadHistory.getLine());
-    assertEquals(1325389904000L, (long) parsedReadHistory.
-        getHistoryEndMillis());
-    assertEquals(900L, (long) parsedReadHistory.getIntervalLength());
-    SortedMap<Long, Long> bandwidthValues = parsedReadHistory.
-        getBandwidthValues();
-    assertEquals(4268032L, (long) bandwidthValues.remove(1325387204000L));
-    assertEquals(139264L, (long) bandwidthValues.remove(1325388104000L));
-    assertEquals(7797760L, (long) bandwidthValues.remove(1325389004000L));
-    assertEquals(1415168L, (long) bandwidthValues.remove(1325389904000L));
-    assertTrue(bandwidthValues.isEmpty());
-  }
-
-  @Test()
-  public void testReadHistoryTwoSpaces() throws DescriptorParseException {
-    /* There are some server descriptors from older Tor versions that
-     * contain "opt read-history  " lines. */
-    String readHistoryLine = "opt read-history  2012-01-01 03:51:44 "
-        + "(900 s) 4268032,139264,7797760,1415168";
-    DescriptorBuilder.createWithReadHistoryLine(readHistoryLine);
-  }
-
-  @Test()
-  public void testEventdnsOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithEventdnsLine("opt eventdns 1");
-    assertTrue(descriptor.getUsesEnhancedDnsLogic());
-  }
-
-  @Test()
-  public void testEventdns1() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithEventdnsLine("eventdns 1");
-    assertTrue(descriptor.getUsesEnhancedDnsLogic());
-  }
-
-  @Test()
-  public void testEventdns0() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithEventdnsLine("eventdns 0");
-    assertFalse(descriptor.getUsesEnhancedDnsLogic());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEventdnsTrue() throws DescriptorParseException {
-    DescriptorBuilder.createWithEventdnsLine("eventdns true");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEventdnsNo() throws DescriptorParseException {
-    DescriptorBuilder.createWithEventdnsLine("eventdns no");
-  }
-
-  @Test()
-  public void testCachesExtraInfoOpt() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithCachesExtraInfoLine("opt caches-extra-info");
-    assertTrue(descriptor.getCachesExtraInfo());
-  }
-
-  @Test()
-  public void testCachesExtraInfoNoSpace()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithCachesExtraInfoLine("caches-extra-info");
-    assertTrue(descriptor.getCachesExtraInfo());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testCachesExtraInfoTrue() throws DescriptorParseException {
-    DescriptorBuilder.createWithCachesExtraInfoLine("caches-extra-info "
-        + "true");
-  }
-
-  @Test()
-  public void testAllowSingleHopExitsOpt()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithAllowSingleHopExitsLine("opt allow-single-hop-exits");
-    assertTrue(descriptor.getAllowSingleHopExits());
-  }
-
-  @Test()
-  public void testAllowSingleHopExitsNoSpace()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithAllowSingleHopExitsLine("allow-single-hop-exits");
-    assertTrue(descriptor.getAllowSingleHopExits());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAllowSingleHopExitsTrue()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithAllowSingleHopExitsLine(
-        "allow-single-hop-exits true");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testAllowSingleHopExitsNonAsciiKeyword()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithNonAsciiLineBytes(new byte[] {
-        0x14, (byte) 0xfe, 0x18,                  // non-ascii chars
-        0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d,       // "allow-"
-        0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x2d, // "single-"
-        0x68, 0x6f, 0x70, 0x2d,                   // "hop-"
-        0x65, 0x78, 0x69, 0x74, 0x73 },           // "exits" (no newline)
-        false);
-  }
-
-  @Test()
-  public void testIpv6PolicyLine() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithIpv6PolicyLine("ipv6-policy accept 80,1194,1220,1293");
-    assertEquals("accept", descriptor.getIpv6DefaultPolicy());
-    assertEquals("80,1194,1220,1293", descriptor.getIpv6PortList());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIpv6PolicyLineNoPolicy()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIpv6PolicyLineNoPorts()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy accept");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIpv6PolicyLineNoPolicyNoPorts()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testIpv6PolicyLineProject()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithIpv6PolicyLine("ipv6-policy project 80");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTwoIpv6PolicyLines() throws DescriptorParseException {
-    DescriptorBuilder.createWithIpv6PolicyLine(
-        "ipv6-policy accept 80,1194,1220,1293\n"
-        + "ipv6-policy accept 80,1194,1220,1293");
-  }
-
-  @Test()
-  public void testNtorOnionKeyLine() throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithNtorOnionKeyLine("ntor-onion-key "
-        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY=");
-    assertEquals("Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY",
-        descriptor.getNtorOnionKey());
-  }
-
-  @Test()
-  public void testNtorOnionKeyLineNoPadding()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithNtorOnionKeyLine("ntor-onion-key "
-        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY");
-    assertEquals("Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY",
-        descriptor.getNtorOnionKey());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNtorOnionKeyLineNoKey()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithNtorOnionKeyLine("ntor-onion-key ");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNtorOnionKeyLineTwoKeys()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithNtorOnionKeyLine("ntor-onion-key "
-        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY "
-        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTwoNtorOnionKeyLines() throws DescriptorParseException {
-    DescriptorBuilder.createWithNtorOnionKeyLine("ntor-onion-key "
-        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY\nntor-onion-key "
-        + "Y/XgaHcPIJVa4D55kir9QLH8rEYAaLXuv3c3sm8jYhY\n");
-  }
-
-  @Test()
-  public void testTunnelledDirServerTrue()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder
-        .createWithTunnelledDirServerLine("tunnelled-dir-server");
-    assertTrue(descriptor.getTunnelledDirServer());
-  }
-
-  @Test()
-  public void testTunnelledDirServerFalse()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor = DescriptorBuilder
-        .createWithTunnelledDirServerLine(null);
-    assertFalse(descriptor.getTunnelledDirServer());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTunnelledDirServerTypo()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithTunnelledDirServerLine(
-        "tunneled-dir-server");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTunnelledDirServerTwice()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithTunnelledDirServerLine(
-        "tunnelled-dir-server\ntunnelled-dir-server");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testTunnelledDirServerArgs()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithTunnelledDirServerLine(
-        "tunnelled-dir-server 1");
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testUnrecognizedLineFail()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    DescriptorBuilder.createWithUnrecognizedLine(unrecognizedLine, true);
-  }
-
-  @Test()
-  public void testUnrecognizedLineIgnore()
-      throws DescriptorParseException {
-    String unrecognizedLine = "unrecognized-line 1";
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithUnrecognizedLine(unrecognizedLine, false);
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add(unrecognizedLine);
-    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
-  }
-
-  @Test()
-  public void testSomeOtherKey() throws DescriptorParseException {
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add("some-other-key");
-    unrecognizedLines.add("-----BEGIN RSA PUBLIC KEY-----");
-    unrecognizedLines.add("MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ"
-        + "1U4V9SeiKooSo5BpPL");
-    unrecognizedLines.add("o3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3ol"
-        + "IynCI4QryfCEuC3cTF");
-    unrecognizedLines.add("9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKF"
-        + "facOkpAgMBAAE=");
-    unrecognizedLines.add("-----END RSA PUBLIC KEY-----");
-    StringBuilder sb = new StringBuilder();
-    for (String line : unrecognizedLines) {
-      sb.append("\n").append(line);
-    }
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithUnrecognizedLine(sb.toString().substring(1), false);
-    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
-  }
-
-  @Test()
-  public void testUnrecognizedCryptoBlockNoKeyword()
-      throws DescriptorParseException {
-    List<String> unrecognizedLines = new ArrayList<>();
-    unrecognizedLines.add("-----BEGIN RSA PUBLIC KEY-----");
-    unrecognizedLines.add("MIGJAoGBAKM+iiHhO6eHsvd6Xjws9z9EQB1V/Bpuy5ciGJ"
-        + "1U4V9SeiKooSo5BpPL");
-    unrecognizedLines.add("o3XT+6PIgzl3R6uycjS3Ejk47vLEJdcVTm/VG6E0ppu3ol"
-        + "IynCI4QryfCEuC3cTF");
-    unrecognizedLines.add("9wE4WXY4nX7w0RTN18UVLxrt1A9PP0cobFNiPs9rzJCbKF"
-        + "facOkpAgMBAAE=");
-    unrecognizedLines.add("-----END RSA PUBLIC KEY-----");
-    StringBuilder sb = new StringBuilder();
-    for (String line : unrecognizedLines) {
-      sb.append("\n").append(line);
-    }
-    ServerDescriptor descriptor = DescriptorBuilder.
-        createWithUnrecognizedLine(sb.toString().substring(1), false);
-    assertEquals(unrecognizedLines, descriptor.getUnrecognizedLines());
-  }
-
-  private static final String IDENTITY_ED25519_LINES =
-      "identity-ed25519\n"
-      + "-----BEGIN ED25519 CERT-----\n"
-      + "AQQABiX1AVGv5BuzJroQXbOh6vv1nbwc5rh2S13PyRFuLhTiifK4AQAgBACBCMwr"
-      + "\n4qgIlFDIzoC9ieJOtSkwrK+yXJPKlP8ojvgkx8cGKvhokOwA1eYDombzfwHcJ1"
-      + "EV\nbhEn/6g8i7wzO3LoqefIUrSAeEExOAOmm5mNmUIzL8EtnT6JHCr/sqUTUgA="
-      + "\n"
-      + "-----END ED25519 CERT-----";
-
-  private static final String MASTER_KEY_ED25519_LINE =
-      "master-key-ed25519 gQjMK+KoCJRQyM6AvYniTrUpMKyvslyTypT/KI74JMc";
-
-  private static final String ROUTER_SIG_ED25519_LINE =
-      "router-sig-ed25519 y7WF9T2GFwkSDPZEhB55HgquIFOl5uXUFMYJPq3CXXUTKeJ"
-      + "kSrtaZUB5s34fWdHQNtl84mH4dVaFMunHnwgYAw";
-
-  @Test()
-  public void testEd25519() throws DescriptorParseException {
-    ServerDescriptor descriptor =
-        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
-    assertEquals(IDENTITY_ED25519_LINES.substring(
-        IDENTITY_ED25519_LINES.indexOf("\n") + 1),
-        descriptor.getIdentityEd25519());
-    assertEquals(MASTER_KEY_ED25519_LINE.substring(
-        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
-        descriptor.getMasterKeyEd25519());
-    assertEquals(ROUTER_SIG_ED25519_LINE.substring(
-        ROUTER_SIG_ED25519_LINE.indexOf(" ") + 1),
-        descriptor.getRouterSignatureEd25519());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519IdentityMasterKeyMismatch()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        "master-key-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
-        ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testEd25519IdentityMissing()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(null,
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519IdentityDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES + "\n"
-        + IDENTITY_ED25519_LINES, MASTER_KEY_ED25519_LINE,
-        ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519IdentityEmptyCrypto()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines("identity-ed25519\n"
-        + "-----BEGIN ED25519 CERT-----\n-----END ED25519 CERT-----",
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testEd25519MasterKeyMissing()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor =
-        DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        null, ROUTER_SIG_ED25519_LINE);
-    assertEquals(MASTER_KEY_ED25519_LINE.substring(
-        MASTER_KEY_ED25519_LINE.indexOf(" ") + 1),
-        descriptor.getMasterKeyEd25519());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519MasterKeyDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE + "\n" + MASTER_KEY_ED25519_LINE,
-        ROUTER_SIG_ED25519_LINE);
-  }
-
-  @Test()
-  public void testEd25519RouterSigMissing()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE, null);
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testEd25519RouterSigDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithEd25519Lines(IDENTITY_ED25519_LINES,
-        MASTER_KEY_ED25519_LINE, ROUTER_SIG_ED25519_LINE + "\n"
-        + ROUTER_SIG_ED25519_LINE);
-  }
-
-  private static final String ONION_KEY_CROSSCERT_LINES =
-      "onion-key-crosscert\n"
-      + "-----BEGIN CROSSCERT-----\n"
-      + "gVWpiNgG2FekW1uonr4KKoqykjr4bqUBKGZfu6s9rvsV1TThnquZNP6ZhX2IPdQA"
-      + "\nlfKtzFggGu/4BiJ5oTSDj2sK2DMjY3rjrMQZ3I/wJ25yhc9gxjqYqUYO9MmJwA"
-      + "Lp\nfYkqp/t4WchJpyva/4hK8vITsI6eT2BfY/DWMy/suIE=\n"
-      + "-----END CROSSCERT-----";
-
-  private static final String NTOR_ONION_KEY_CROSSCERT_LINES =
-      "ntor-onion-key-crosscert 1\n"
-      + "-----BEGIN ED25519 CERT-----\n"
-      + "AQoABiUeAdauu1MxYGMmGLTCPaoes0RvW7udeLc1t8LZ4P3CDo5bAN4nrRfbCfOt"
-      + "\nz2Nwqn8tER1a+Ry6Vs+ilMZA55Rag4+f6Zdb1fmHWknCxbQlLHpqHACMtemPda"
-      + "Ka\nErPtMuiEqAc=\n"
-      + "-----END ED25519 CERT-----";
-
-  @Test()
-  public void testOnionKeyCrosscert() throws DescriptorParseException {
-    ServerDescriptor descriptor =
-        DescriptorBuilder.createWithOnionKeyCrosscertLines(
-        ONION_KEY_CROSSCERT_LINES);
-    assertEquals(ONION_KEY_CROSSCERT_LINES.substring(
-        ONION_KEY_CROSSCERT_LINES.indexOf("\n") + 1),
-        descriptor.getOnionKeyCrosscert());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testOnionKeyCrosscertDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithOnionKeyCrosscertLines(
-    ONION_KEY_CROSSCERT_LINES + "\n" + ONION_KEY_CROSSCERT_LINES);
-  }
-
-  @Test()
-  public void testNtorOnionKeyCrosscert()
-      throws DescriptorParseException {
-    ServerDescriptor descriptor =
-        DescriptorBuilder.createWithNtorOnionKeyCrosscertLines(
-        NTOR_ONION_KEY_CROSSCERT_LINES);
-    assertEquals(NTOR_ONION_KEY_CROSSCERT_LINES.substring(
-        NTOR_ONION_KEY_CROSSCERT_LINES.indexOf("\n") + 1),
-        descriptor.getNtorOnionKeyCrosscert());
-    assertEquals(1, descriptor.getNtorOnionKeyCrosscertSign());
-  }
-
-  @Test(expected = DescriptorParseException.class)
-  public void testNtorOnionKeyCrosscertDuplicate()
-      throws DescriptorParseException {
-    DescriptorBuilder.createWithOnionKeyCrosscertLines(
-        NTOR_ONION_KEY_CROSSCERT_LINES + "\n"
-        + NTOR_ONION_KEY_CROSSCERT_LINES);
-  }
-}
-
diff --git a/test/org/torproject/descriptor/impl/TorperfResultImplTest.java b/test/org/torproject/descriptor/impl/TorperfResultImplTest.java
deleted file mode 100644
index b5cde0a..0000000
--- a/test/org/torproject/descriptor/impl/TorperfResultImplTest.java
+++ /dev/null
@@ -1,97 +0,0 @@
-/* Copyright 2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.descriptor.impl;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-
-import java.util.List;
-
-import org.junit.Test;
-import org.torproject.descriptor.Descriptor;
-
-public class TorperfResultImplTest {
-
-  @Test()
-  public void testAnnotatedInput() throws Exception{
-    TorperfResultImpl result = (TorperfResultImpl)
-        (TorperfResultImpl.parseTorperfResults((torperfAnnotation + input)
-        .getBytes("US-ASCII"), false).get(0));
-    assertEquals("Expected one annotation.", 1,
-        result.getAnnotations().size());
-    assertEquals(torperfAnnotation.substring(0, 17),
-        result.getAnnotations().get(0));
-    int count = 0;
-    for (Long l: result.getDataPercentiles().values()) {
-      assertNotNull(l);
-      assertEquals(l.longValue(), deciles[count++]);
-    }
-  }
-
-  @Test()
-  public void testPartiallyAnnotatedInput() throws Exception{
-    byte[] asciiBytes = (torperfAnnotation
-        + input + input + input).getBytes("US-ASCII");
-    List<Descriptor> result = TorperfResultImpl.parseTorperfResults(
-        asciiBytes, false);
-    assertEquals("Expected one annotation.", 1,
-        ((TorperfResultImpl)(result.get(0))).getAnnotations().size());
-    assertEquals(3, result.size());
-    assertEquals("Expected zero annotations.", 0,
-        ((TorperfResultImpl)(result.get(1))).getAnnotations().size());
-    assertEquals("Expected zero annotations.", 0,
-        ((TorperfResultImpl)(result.get(2))).getAnnotations().size());
-  }
-
-  @Test()
-  public void testAllAnnotatedInput() throws Exception {
-    byte[] asciiBytes = (torperfAnnotation + input
-        + torperfAnnotation + input
-        + torperfAnnotation + input).getBytes("US-ASCII");
-    List<Descriptor> result = TorperfResultImpl.parseTorperfResults(
-        asciiBytes, false);
-    assertEquals("Expected one annotation.", 1,
-        ((TorperfResultImpl)(result.get(0))).getAnnotations().size());
-    assertEquals(3, result.size());
-    assertEquals("Expected one annotation.", 1,
-        ((TorperfResultImpl)(result.get(1))).getAnnotations().size());
-    assertEquals("Expected one annotation.", 1,
-        ((TorperfResultImpl)(result.get(2))).getAnnotations().size());
-  }
-
-  private static long[] deciles = new long[] {
-      1441065602980L, 1441065603030L, 1441065603090L, 1441065603120L,
-      1441065603230L, 1441065603250L, 1441065603310L, 1441065603370L,
-      1441065603370L };
-
-  private static final String torperfAnnotation = "@type torperf 1.0\n";
-
-  private static final String input =
-      "BUILDTIMES=0.872834920883,1.09103679657,1.49180984497 "
-      + "CIRC_ID=1228 CONNECT=1441065601.86 DATACOMPLETE=1441065603.39 "
-      + "DATAPERC10=1441065602.98 DATAPERC20=1441065603.03 "
-      + "DATAPERC30=1441065603.09 DATAPERC40=1441065603.12 "
-      + "DATAPERC50=1441065603.23 DATAPERC60=1441065603.25 "
-      + "DATAPERC70=1441065603.31 DATAPERC80=1441065603.37 "
-      + "DATAPERC90=1441065603.37 DATAREQUEST=1441065602.38 "
-      + "DATARESPONSE=1441065602.84 DIDTIMEOUT=0 FILESIZE=51200 "
-      + "LAUNCH=1441065361.30 NEGOTIATE=1441065601.86 "
-      + "PATH=$C4C9C332D25B3546BEF4E1250CF410E97EF996E6,"
-      + "$C43FA6474A9F071E9120DF63ED6EB8FDBA105234,"
-      + "$7C0AA4E3B73E407E9F5FEB1912F8BE26D8AA124D QUANTILE=0.800000 "
-      + "READBYTES=51416 REQUEST=1441065601.86 RESPONSE=1441065602.38 "
-      + "SOCKET=1441065601.86 SOURCE=moria START=1441065601.86 "
-      + "TIMEOUT=1500 USED_AT=1441065603.40 USED_BY=2475 WRITEBYTES=75\n";
-
-  @Test()
-  public void testDatapercNonNumeric() throws Exception {
-    List<Descriptor> result = TorperfResultImpl.parseTorperfResults(
-        ("DATAPERMILLE=2.0 " + input).getBytes(), false);
-    assertEquals(1, result.size());
-    TorperfResultImpl torperfResult = (TorperfResultImpl) result.get(0);
-    assertEquals(1, torperfResult.getUnrecognizedKeys().size());
-    assertEquals("DATAPERMILLE",
-        torperfResult.getUnrecognizedKeys().firstKey());
-  }
-}
-



_______________________________________________
tor-commits mailing list
tor-commits@xxxxxxxxxxxxxxxxxxxx
https://lists.torproject.org/cgi-bin/mailman/listinfo/tor-commits