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

[tor-commits] [metrics-lib/master] Add BandwidthFile for parsed bandwidth files.



commit 25072720b90f5f725c50ee7b645efc4777d68da6
Author: Karsten Loesing <karsten.loesing@xxxxxxx>
Date:   Sat Apr 20 11:04:14 2019 +0200

    Add BandwidthFile for parsed bandwidth files.
    
    Implements #30216.
---
 CHANGELOG.md                                       |   6 +
 .../org/torproject/descriptor/BandwidthFile.java   | 249 +++++++++
 .../descriptor/impl/BandwidthFileImpl.java         | 431 +++++++++++++++
 .../descriptor/impl/DescriptorParserImpl.java      |   9 +
 .../descriptor/impl/BandwidthFileImplTest.java     | 596 +++++++++++++++++++++
 .../descriptor/impl/TestDescriptorBuilder.java     | 120 +++++
 6 files changed, 1411 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3353a4c..d9cb62a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# Changes in version 2.6.0 - 2019-04-??
+
+ * Medium changes
+   - Add new BandwidthFile descriptor for parsed bandwidth files.
+
+
 # Changes in version 2.5.0 - 2018-09-25
 
  * Medium changes
diff --git a/src/main/java/org/torproject/descriptor/BandwidthFile.java b/src/main/java/org/torproject/descriptor/BandwidthFile.java
new file mode 100644
index 0000000..34b9414
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/BandwidthFile.java
@@ -0,0 +1,249 @@
+/* Copyright 2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * A bandwidth file contains information on relays' bandwidth capacities and is
+ * produced by bandwidth generators, previously known as bandwidth scanners.
+ *
+ * @since 2.6.0
+ */
+public interface BandwidthFile extends Descriptor {
+
+  /**
+   * Time of the most recent generator bandwidth result.
+   *
+   * @since 2.6.0
+   */
+  LocalDateTime timestamp();
+
+  /**
+   * Document format version.
+   *
+   * @since 2.6.0
+   */
+  String version();
+
+  /**
+   * Name of the software that created the document.
+   *
+   * @since 2.6.0
+   */
+  String software();
+
+  /**
+   * Version of the software that created the document.
+   *
+   * @since 2.6.0
+   */
+  Optional<String> softwareVersion();
+
+  /**
+   * Timestamp in UTC time zone when the file was created.
+   *
+   * @since 2.6.0
+   */
+  Optional<LocalDateTime> fileCreated();
+
+  /**
+   * Timestamp in UTC time zone when the generator was started.
+   *
+   * @since 2.6.0
+   */
+  Optional<LocalDateTime> generatorStarted();
+
+  /**
+   * Timestamp in UTC time zone when the first relay bandwidth was obtained.
+   *
+   * @since 2.6.0
+   */
+  Optional<LocalDateTime> earliestBandwidth();
+
+  /**
+   * Timestamp in UTC time zone of the most recent generator bandwidth result.
+   *
+   * @since 2.6.0
+   */
+  Optional<LocalDateTime> latestBandwidth();
+
+  /**
+   * Number of relays that have enough measurements to be included in the
+   * bandwidth file.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> numberEligibleRelays();
+
+  /**
+   * Percentage of relays in the consensus that should be included in every
+   * generated bandwidth file.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> minimumPercentEligibleRelays();
+
+  /**
+   * Number of relays in the consensus.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> numberConsensusRelays();
+
+  /**
+   * The number of eligible relays, as a percentage of the number of relays in
+   * the consensus.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> percentEligibleRelays();
+
+  /**
+   * Minimum number of relays that should be included in the bandwidth file.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> minimumNumberEligibleRelays();
+
+  /**
+   * Country, as in political geolocation, where the generator is run.
+   *
+   * @since 2.6.0
+   */
+  Optional<String> scannerCountry();
+
+  /**
+   * Country, as in political geolocation, or countries where the destination
+   * web server(s) are located.
+   *
+   * @since 2.6.0
+   */
+  Optional<String[]> destinationsCountries();
+
+  /**
+   * Number of the different consensuses seen in the last data period.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentConsensusCount();
+
+  /**
+   * Number of times that a list with a subset of relays prioritized to be
+   * measured has been created in the last data period.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentPriorityListCount();
+
+  /**
+   * Number of relays that has been in in the list of relays prioritized to be
+   * measured in the last data period.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentPriorityRelayCount();
+
+  /**
+   * Number of times that any relay has been queued to be measured in the last
+   * data period.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentMeasurementAttemptCount();
+
+  /**
+   * Number of times that the scanner attempted to measure a relay in the last
+   * data period, but the relay has not been measured because of system, network
+   * or implementation issues.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentMeasurementFailureCount();
+
+  /**
+   * Number of relays that have no successful measurements in the last data
+   * period.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentMeasurementsExcludedErrorCount();
+
+  /**
+   * Number of relays that have some successful measurements in the last data
+   * period, but all those measurements were performed in a period of time that
+   * was too short.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentMeasurementsExcludedNearCount();
+
+  /**
+   * Number of relays that have some successful measurements, but all those
+   * measurements are too old.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentMeasurementsExcludedOldCount();
+
+  /**
+   * Number of relays that don't have enough recent successful measurements.
+   *
+   * @since 2.6.0
+   */
+  Optional<Integer> recentMeasurementsExcludedFewCount();
+
+  /**
+   * Time that it would take to report measurements about half of the network,
+   * given the number of eligible relays and the time it took in the last days.
+   *
+   * @since 2.6.0
+   */
+  Optional<Duration> timeToReportHalfNetwork();
+
+  /**
+   * List of zero or more {@link RelayLine}s containing relay identities and
+   * bandwidths in the order as they are contained in the bandwidth file.
+   *
+   * @since 2.6.0
+   */
+  List<RelayLine> relayLines();
+
+  interface RelayLine {
+
+    /**
+     * Fingerprint for the relay's RSA identity key.
+     *
+     * @since 2.6.0
+     */
+    Optional<String> nodeId();
+
+    /**
+     * Relays's master Ed25519 key, base64 encoded, without trailing "="s.
+     *
+     * @since 2.6.0
+     */
+    Optional<String> masterKeyEd25519();
+
+    /**
+     * Bandwidth of this relay in kilobytes per second.
+     *
+     * @since 2.6.0
+     */
+    int bw();
+
+    /**
+     * Additional relay key-value pairs, excluding the key value pairs already
+     * parsed for relay identities and bandwidths.
+     *
+     * @since 2.6.0
+     */
+    Map<String, String> additionalKeyValues();
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/BandwidthFileImpl.java b/src/main/java/org/torproject/descriptor/impl/BandwidthFileImpl.java
new file mode 100644
index 0000000..5d661e4
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/BandwidthFileImpl.java
@@ -0,0 +1,431 @@
+/* Copyright 2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.BandwidthFile;
+import org.torproject.descriptor.DescriptorParseException;
+
+import java.io.File;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Scanner;
+
+public class BandwidthFileImpl extends DescriptorImpl implements BandwidthFile {
+
+  private enum KeyWithStringValue {
+    version, software, software_version
+  }
+
+  private enum KeyWithLocalDateTimeValue {
+    file_created, generator_started, earliest_bandwidth, latest_bandwidth
+  }
+
+  private enum KeyWithIntValue {
+    number_eligible_relays, minimum_percent_eligible_relays,
+    number_consensus_relays, percent_eligible_relays,
+    minimum_number_eligible_relays, recent_consensus_count,
+    recent_priority_list_count, recent_priority_relay_count,
+    recent_measurement_attempt_count, recent_measurement_failure_count,
+    recent_measurements_excluded_error_count,
+    recent_measurements_excluded_near_count,
+    recent_measurements_excluded_old_count,
+    recent_measurements_excluded_few_count
+  }
+
+  BandwidthFileImpl(byte[] rawDescriptorBytes, File descriptorfile)
+      throws DescriptorParseException {
+    super(rawDescriptorBytes, new int[] { 0, rawDescriptorBytes.length },
+        descriptorfile, false);
+    Scanner scanner = this.newScanner().useDelimiter("\n");
+    this.parseTimestampLine(scanner.nextLine());
+    boolean haveFinishedParsingHeader = false;
+    while (scanner.hasNext()) {
+      String line = scanner.nextLine();
+      if (!haveFinishedParsingHeader) {
+        if (line.startsWith("bw=") || line.contains(" bw=")) {
+          haveFinishedParsingHeader = true;
+        } else if ("====".equals(line) || "=====".equals(line)) {
+          haveFinishedParsingHeader = true;
+          continue;
+        }
+      }
+      if (!haveFinishedParsingHeader) {
+        this.parseHeaderLine(line);
+      } else {
+        this.parseRelayLine(line);
+      }
+    }
+  }
+
+  private void parseTimestampLine(String line) throws DescriptorParseException {
+    try {
+      this.timestamp = LocalDateTime.ofInstant(Instant.ofEpochSecond(
+          Long.parseLong(line)), ZoneOffset.UTC);
+    } catch (NumberFormatException | DateTimeParseException e) {
+      throw new DescriptorParseException(String.format(
+          "Unable to parse timestamp in first line: '%s'.", line), e);
+    }
+  }
+
+  private void parseHeaderLine(String line) throws DescriptorParseException {
+    String[] keyValueParts = line.split("=", 2);
+    if (keyValueParts.length != 2) {
+      throw new DescriptorParseException(String.format(
+          "Unrecognized line '%s' without '=' character.", line));
+    }
+    String key = keyValueParts[0];
+    if (key.length() < 1) {
+      throw new DescriptorParseException(String.format(
+          "Unrecognized line '%s' starting with '=' character.", line));
+    }
+    String value = keyValueParts[1];
+    switch (key) {
+      case "version":
+      case "software":
+      case "software_version":
+        this.parsedStrings.put(KeyWithStringValue.valueOf(key), value);
+        break;
+      case "file_created":
+      case "generator_started":
+      case "earliest_bandwidth":
+      case "latest_bandwidth":
+        try {
+          this.parsedLocalDateTimes.put(KeyWithLocalDateTimeValue.valueOf(key),
+              LocalDateTime.parse(value));
+        } catch (DateTimeParseException e) {
+          throw new DescriptorParseException(String.format(
+              "Unable to parse date-time string: '%s'.", value), e);
+        }
+        break;
+      case "number_eligible_relays":
+      case "minimum_percent_eligible_relays":
+      case "number_consensus_relays":
+      case "percent_eligible_relays":
+      case "minimum_number_eligible_relays":
+      case "recent_consensus_count":
+      case "recent_priority_list_count":
+      case "recent_priority_relay_count":
+      case "recent_measurement_attempt_count":
+      case "recent_measurement_failure_count":
+      case "recent_measurements_excluded_error_count":
+      case "recent_measurements_excluded_near_count":
+      case "recent_measurements_excluded_old_count":
+      case "recent_measurements_excluded_few_count":
+        try {
+          this.parsedInts.put(KeyWithIntValue.valueOf(key),
+              Integer.parseInt(value));
+        } catch (NumberFormatException e) {
+          throw new DescriptorParseException(String.format(
+              "Unable to parse int: '%s'.", value), e);
+        }
+        break;
+      case "scanner_country":
+        if (!value.matches("[A-Z]{2}")) {
+          throw new DescriptorParseException(String.format(
+              "Invalid country code '%s'.", value));
+        }
+        this.scannerCountry = value;
+        break;
+      case "destinations_countries":
+        if (!value.matches("[A-Z]{2}(,[A-Z]{2})*")) {
+          throw new DescriptorParseException(String.format(
+              "Invalid country code list '%s'.", value));
+        }
+        this.destinationsCountries = value.split(",");
+        break;
+      case "time_to_report_half_network":
+        try {
+          this.timeToReportHalfNetwork
+              = Duration.ofSeconds(Long.parseLong(value));
+        } catch (NumberFormatException | DateTimeParseException e) {
+          throw new DescriptorParseException(String.format(
+              "Unable to parse duration: '%s'.", value), e);
+        }
+        break;
+      case "node_id":
+      case "master_key_ed25519":
+      case "bw":
+        throw new DescriptorParseException(String.format(
+            "Either additional header line must not use keywords specified in "
+            + "relay lines, or relay line is missing required keys: '%s'.",
+            line));
+      default:
+        /* Ignore additional header lines. */
+    }
+  }
+
+  private class RelayLineImpl implements RelayLine {
+
+    private String nodeId;
+
+    @Override
+    public Optional<String> nodeId() {
+      return Optional.ofNullable(this.nodeId);
+    }
+
+    private String masterKeyEd25519;
+
+    @Override
+    public Optional<String> masterKeyEd25519() {
+      return Optional.ofNullable(this.masterKeyEd25519);
+    }
+
+    private int bw;
+
+    @Override
+    public int bw() {
+      return this.bw;
+    }
+
+    private Map<String, String> additionalKeyValues;
+
+    @Override
+    public Map<String, String> additionalKeyValues() {
+      return null == this.additionalKeyValues ? Collections.emptyMap()
+          : Collections.unmodifiableMap(this.additionalKeyValues);
+    }
+
+    private RelayLineImpl(String nodeId, String masterKeyEd25519, int bw,
+        Map<String, String> additionalKeyValues) {
+      this.nodeId = nodeId;
+      this.masterKeyEd25519 = masterKeyEd25519;
+      this.bw = bw;
+      this.additionalKeyValues = additionalKeyValues;
+    }
+  }
+
+  private void parseRelayLine(String line) throws DescriptorParseException {
+    String[] spaceSeparatedLineParts = line.split(" ");
+    String nodeId = null;
+    String masterKeyEd25519 = null;
+    Integer bw = null;
+    Map<String, String> additionalKeyValues = new LinkedHashMap<>();
+    for (String spaceSeparatedLinePart : spaceSeparatedLineParts) {
+      String[] keyValueParts = spaceSeparatedLinePart.split("=", 2);
+      if (keyValueParts.length != 2) {
+        throw new DescriptorParseException(String.format(
+            "Unrecognized space-separated line part '%s' without '=' "
+                + "character in line '%s'.", spaceSeparatedLinePart, line));
+      }
+      String key = keyValueParts[0];
+      if (key.length() < 1) {
+        throw new DescriptorParseException(String.format(
+            "Unrecognized space-separated line part '%s' starting with '=' "
+                + "character in line '%s'.", spaceSeparatedLinePart, line));
+      }
+      String value = keyValueParts[1];
+      switch (key) {
+        case "node_id":
+          nodeId = value;
+          break;
+        case "master_key_ed25519":
+          masterKeyEd25519 = value;
+          break;
+        case "bw":
+          try {
+            bw = Integer.parseInt(value);
+          } catch (NumberFormatException e) {
+            throw new DescriptorParseException(String.format(
+                "Unable to parse bw '%s' in line '%s'.", value, line), e);
+          }
+          break;
+        default:
+          additionalKeyValues.put(key, value);
+      }
+    }
+    if (null == nodeId && null == masterKeyEd25519) {
+      throw new DescriptorParseException(String.format(
+          "Expected relay line, but line contains neither node_id nor "
+          + "master_key_ed25519: '%s'.", line));
+    }
+    if (null == bw) {
+      throw new DescriptorParseException(String.format(
+          "Expected relay line, but line does not contain bw: '%s'.", line));
+    }
+    this.relayLines.add(new RelayLineImpl(nodeId, masterKeyEd25519, bw,
+        additionalKeyValues.isEmpty() ? null : additionalKeyValues));
+  }
+
+  private LocalDateTime timestamp;
+
+  @Override
+  public LocalDateTime timestamp() {
+    return this.timestamp;
+  }
+
+  private EnumMap<KeyWithStringValue, String> parsedStrings
+      = new EnumMap<>(KeyWithStringValue.class);
+
+  @Override
+  public String version() {
+    return this.parsedStrings.getOrDefault(KeyWithStringValue.version,
+        "1.0.0");
+  }
+
+  @Override
+  public String software() {
+    return this.parsedStrings.getOrDefault(KeyWithStringValue.software,
+        "torflow");
+  }
+
+  @Override
+  public Optional<String> softwareVersion() {
+    return Optional.ofNullable(
+        this.parsedStrings.get(KeyWithStringValue.software_version));
+  }
+
+  private EnumMap<KeyWithLocalDateTimeValue, LocalDateTime> parsedLocalDateTimes
+      = new EnumMap<>(KeyWithLocalDateTimeValue.class);
+
+  @Override
+  public Optional<LocalDateTime> fileCreated() {
+    return Optional.ofNullable(this.parsedLocalDateTimes.get(
+        KeyWithLocalDateTimeValue.file_created));
+  }
+
+  @Override
+  public Optional<LocalDateTime> generatorStarted() {
+    return Optional.ofNullable(this.parsedLocalDateTimes.get(
+        KeyWithLocalDateTimeValue.generator_started));
+  }
+
+  @Override
+  public Optional<LocalDateTime> earliestBandwidth() {
+    return Optional.ofNullable(this.parsedLocalDateTimes.get(
+        KeyWithLocalDateTimeValue.earliest_bandwidth));
+  }
+
+  @Override
+  public Optional<LocalDateTime> latestBandwidth() {
+    return Optional.ofNullable(this.parsedLocalDateTimes.get(
+        KeyWithLocalDateTimeValue.latest_bandwidth));
+  }
+
+  private EnumMap<KeyWithIntValue, Integer> parsedInts
+      = new EnumMap<>(KeyWithIntValue.class);
+
+  @Override
+  public Optional<Integer> numberEligibleRelays() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.number_eligible_relays));
+  }
+
+  @Override
+  public Optional<Integer> minimumPercentEligibleRelays() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.minimum_percent_eligible_relays));
+  }
+
+  @Override
+  public Optional<Integer> numberConsensusRelays() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.number_consensus_relays));
+  }
+
+  @Override
+  public Optional<Integer> percentEligibleRelays() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.percent_eligible_relays));
+  }
+
+  @Override
+  public Optional<Integer> minimumNumberEligibleRelays() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.minimum_number_eligible_relays));
+  }
+
+  private String scannerCountry;
+
+  @Override
+  public Optional<String> scannerCountry() {
+    return Optional.ofNullable(this.scannerCountry);
+  }
+
+  private String[] destinationsCountries;
+
+  @Override
+  public Optional<String[]> destinationsCountries() {
+    return Optional.ofNullable(this.destinationsCountries);
+  }
+
+  @Override
+  public Optional<Integer> recentConsensusCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_consensus_count));
+  }
+
+  @Override
+  public Optional<Integer> recentPriorityListCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_priority_list_count));
+  }
+
+  @Override
+  public Optional<Integer> recentPriorityRelayCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_priority_relay_count));
+  }
+
+  @Override
+  public Optional<Integer> recentMeasurementAttemptCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_measurement_attempt_count));
+  }
+
+  @Override
+  public Optional<Integer> recentMeasurementFailureCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_measurement_failure_count));
+  }
+
+  @Override
+  public Optional<Integer> recentMeasurementsExcludedErrorCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_measurements_excluded_error_count));
+  }
+
+  @Override
+  public Optional<Integer> recentMeasurementsExcludedNearCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_measurements_excluded_near_count));
+  }
+
+  @Override
+  public Optional<Integer> recentMeasurementsExcludedOldCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_measurements_excluded_old_count));
+  }
+
+  @Override
+  public Optional<Integer> recentMeasurementsExcludedFewCount() {
+    return Optional.ofNullable(this.parsedInts.get(
+        KeyWithIntValue.recent_measurements_excluded_few_count));
+  }
+
+  private Duration timeToReportHalfNetwork;
+
+  @Override
+  public Optional<Duration> timeToReportHalfNetwork() {
+    return Optional.ofNullable(this.timeToReportHalfNetwork);
+  }
+
+  private List<RelayLine> relayLines = new ArrayList<>();
+
+  @Override
+  public List<RelayLine> relayLines() {
+    return this.relayLines.isEmpty() ? Collections.emptyList()
+        : Collections.unmodifiableList(this.relayLines);
+  }
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
index e8b8b08..119fe09 100644
--- a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
@@ -132,6 +132,15 @@ public class DescriptorParserImpl implements DescriptorParser {
           sourceFile);
     } else if (fileName.contains(LogDescriptorImpl.MARKER)) {
       return LogDescriptorImpl.parse(rawDescriptorBytes, sourceFile, fileName);
+    } else if (firstLines.matches("^[0-9]{10}\\n")) {
+      /* Identifying bandwidth files by a 10-digit timestamp in the first line
+       * breaks with files generated before 2002 or after 2286 and when the next
+       * descriptor identifier starts with just a timestamp in the first line
+       * rather than a document type identifier. */
+      List<Descriptor> parsedDescriptors = new ArrayList<>();
+      parsedDescriptors.add(new BandwidthFileImpl(rawDescriptorBytes,
+          sourceFile));
+      return parsedDescriptors;
     } else {
       throw new DescriptorParseException("Could not detect descriptor "
           + "type in descriptor starting with '" + firstLines + "'.");
diff --git a/src/test/java/org/torproject/descriptor/impl/BandwidthFileImplTest.java b/src/test/java/org/torproject/descriptor/impl/BandwidthFileImplTest.java
new file mode 100644
index 0000000..d19b7e7
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/BandwidthFileImplTest.java
@@ -0,0 +1,596 @@
+/* Copyright 2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import org.torproject.descriptor.BandwidthFile;
+import org.torproject.descriptor.DescriptorParseException;
+
+import org.hamcrest.Matchers;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+public class BandwidthFileImplTest {
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  /**
+   * Example from bandwidth-file-spec.txt: Version 1.0.0, generated by Torflow.
+   */
+  private static final String[] specExample100 = new String[] {
+      "1523911758",
+      "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80 bw=760 nick=Test "
+          + "measured_at=1523911725 updated_at=1523911725 "
+          + "pid_error=4.11374090719 pid_error_sum=4.11374090719 "
+          + "pid_bw=57136645 pid_delta=2.12168374577 circ_fail=0.2 "
+          + "scanner=/filepath",
+      "node_id=$96C15995F30895689291F455587BD94CA427B6FC bw=189 nick=Test2 "
+          + "measured_at=1523911623 updated_at=1523911623 "
+          + "pid_error=3.96703337994 pid_error_sum=3.96703337994 "
+          + "pid_bw=47422125 pid_delta=2.65469736988 circ_fail=0.0 "
+          + "scanner=/filepath" };
+
+  @Test
+  public void testSpecExample100() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample100).build(), null);
+    assertEquals(LocalDateTime.of(2018, 4, 16, 20, 49, 18),
+        bandwidthFile.timestamp());
+    assertEquals("1.0.0", bandwidthFile.version());
+    assertEquals("torflow", bandwidthFile.software());
+    assertFalse(bandwidthFile.softwareVersion().isPresent());
+    assertFalse(bandwidthFile.fileCreated().isPresent());
+    assertFalse(bandwidthFile.generatorStarted().isPresent());
+    assertFalse(bandwidthFile.earliestBandwidth().isPresent());
+    assertFalse(bandwidthFile.latestBandwidth().isPresent());
+    assertFalse(bandwidthFile.numberEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.minimumPercentEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.numberConsensusRelays().isPresent());
+    assertFalse(bandwidthFile.percentEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.minimumNumberEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.scannerCountry().isPresent());
+    assertFalse(bandwidthFile.destinationsCountries().isPresent());
+    assertFalse(bandwidthFile.recentConsensusCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityListCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityRelayCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementAttemptCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementFailureCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedErrorCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedNearCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedOldCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedFewCount().isPresent());
+    assertFalse(bandwidthFile.timeToReportHalfNetwork().isPresent());
+    assertEquals(2, bandwidthFile.relayLines().size());
+    BandwidthFile.RelayLine firstRelayLine = bandwidthFile.relayLines().get(0);
+    assertEquals(Optional.of("$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80"),
+        firstRelayLine.nodeId());
+    assertFalse(firstRelayLine.masterKeyEd25519().isPresent());
+    assertEquals(760, firstRelayLine.bw());
+    Map<String, String> expectedFirstAdditionalKeyValues
+        = new LinkedHashMap<>();
+    expectedFirstAdditionalKeyValues.put("nick", "Test");
+    expectedFirstAdditionalKeyValues.put("measured_at", "1523911725");
+    expectedFirstAdditionalKeyValues.put("updated_at", "1523911725");
+    expectedFirstAdditionalKeyValues.put("pid_error", "4.11374090719");
+    expectedFirstAdditionalKeyValues.put("pid_error_sum", "4.11374090719");
+    expectedFirstAdditionalKeyValues.put("pid_bw", "57136645");
+    expectedFirstAdditionalKeyValues.put("pid_delta", "2.12168374577");
+    expectedFirstAdditionalKeyValues.put("circ_fail", "0.2");
+    expectedFirstAdditionalKeyValues.put("scanner", "/filepath");
+    assertEquals(expectedFirstAdditionalKeyValues,
+        firstRelayLine.additionalKeyValues());
+  }
+
+  @Test
+  public void testTimestampAsKeyValue() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Unable to parse timestamp in first line"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample100)
+        .replaceLineStartingWith("1523911758", "timestamp=1523911758")
+        .build(), null);
+  }
+
+  @Test
+  public void testEmptyLine() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Blank lines are not allowed."));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample100)
+        .appendLines("")
+        .build(), null);
+  }
+
+  @Test
+  public void testHeaderLineAtEnd() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Expected relay line, but line contains neither node_id nor "
+        + "master_key_ed25519"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample100)
+        .appendLines("version=1.0.0")
+        .build(), null);
+  }
+
+  @Test
+  public void testRelayLineWithoutRelayId() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Either additional header line must not use keywords specified in "
+        + "relay lines, or relay line is missing required keys"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample100)
+        .replaceLineStartingWith(
+        "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80",
+        "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80")
+        .build(), null);
+  }
+
+  @Test
+  public void testRelayLineWithoutBw() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Expected relay line, but line contains neither node_id nor "
+        + "master_key_ed25519"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample100)
+        .replaceLineStartingWith(
+        "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80", "bw=760")
+        .build(), null);
+  }
+
+  @Test
+  public void testBwNotANumber() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Unable to parse bw 'slow' in line"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample100)
+        .replaceLineStartingWith(
+            "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80",
+            "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80 bw=slow")
+        .build(), null);
+  }
+
+  @Test
+  public void testRelayLineTrailingSpace() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample100)
+        .replaceLineStartingWith(
+            "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80",
+            "node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80 bw=760 ")
+        .build(), null);
+    /* It's okay that this line ends with a space, we're parsing it anyway. */
+    assertEquals(2, bandwidthFile.relayLines().size());
+    BandwidthFile.RelayLine firstRelayLine = bandwidthFile.relayLines().get(0);
+    assertEquals(Optional.of("$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80"),
+        firstRelayLine.nodeId());
+    assertEquals(760, firstRelayLine.bw());
+  }
+
+  /**
+   * Example from bandwidth-file-spec.txt: Version 1.1.0, generated by sbws
+   * version 0.1.0.
+   */
+  private static final String[] specExample110 = new String[] {
+      "1523911758",
+      "version=1.1.0",
+      "software=sbws",
+      "software_version=0.1.0",
+      "latest_bandwidth=2018-04-16T20:49:18",
+      "file_created=2018-04-16T21:49:18",
+      "generator_started=2018-04-16T15:13:25",
+      "earliest_bandwidth=2018-04-16T15:13:26",
+      "====",
+      "bw=380 error_circ=0 error_misc=0 error_stream=1 "
+          + "master_key_ed25519=YaqV4vbvPYKucElk297eVdNArDz9HtIwUoIeo0+cVIpQ "
+          + "nick=Test node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80 "
+          + "rtt=380 success=1 time=2018-05-08T16:13:26",
+      "bw=189 error_circ=0 error_misc=0 error_stream=0 "
+          + "master_key_ed25519=a6a+dZadrQBtfSbmQkP7j2ardCmLnm5NJ4ZzkvDxbo0I "
+          + "nick=Test2 node_id=$96C15995F30895689291F455587BD94CA427B6FC "
+          + "rtt=378 success=1 time=2018-05-08T16:13:36" };
+
+  @Test
+  public void testSpecExample110() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample110).build(), null);
+    assertEquals(LocalDateTime.of(2018, 4, 16, 20, 49, 18),
+        bandwidthFile.timestamp());
+    assertEquals("1.1.0", bandwidthFile.version());
+    assertEquals("sbws", bandwidthFile.software());
+    assertEquals(Optional.of("0.1.0"), bandwidthFile.softwareVersion());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 21, 49, 18)),
+        bandwidthFile.fileCreated());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 25)),
+        bandwidthFile.generatorStarted());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 26)),
+        bandwidthFile.earliestBandwidth());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 20, 49, 18)),
+        bandwidthFile.latestBandwidth());
+    assertFalse(bandwidthFile.numberEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.minimumPercentEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.numberConsensusRelays().isPresent());
+    assertFalse(bandwidthFile.percentEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.minimumNumberEligibleRelays().isPresent());
+    assertFalse(bandwidthFile.scannerCountry().isPresent());
+    assertFalse(bandwidthFile.destinationsCountries().isPresent());
+    assertFalse(bandwidthFile.recentConsensusCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityListCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityRelayCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementAttemptCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementFailureCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedErrorCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedNearCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedOldCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedFewCount().isPresent());
+    assertFalse(bandwidthFile.timeToReportHalfNetwork().isPresent());
+  }
+
+  @Test
+  public void testTerminatorLineTooShort() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Unrecognized line '===' starting with '=' character"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample110)
+        .replaceLineStartingWith("====", "===").build(), null);
+  }
+
+  @Test
+  public void testDateTimeContainingSpace() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Unable to parse date-time string"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample110)
+        .replaceLineStartingWith("earliest_bandwidth",
+        "earliest_bandwidth=2018-04-16 15:13:26")
+        .build(), null);
+  }
+
+  /**
+   * Example from bandwidth-file-spec.txt: Version 1.2.0, generated by sbws
+   * version 1.0.3.
+   */
+  private static final String[] specExample120 = new String[] {
+      "1523911758",
+      "version=1.2.0",
+      "latest_bandwidth=2018-04-16T20:49:18",
+      "file_created=2018-04-16T21:49:18",
+      "generator_started=2018-04-16T15:13:25",
+      "earliest_bandwidth=2018-04-16T15:13:26",
+      "minimum_number_eligible_relays=3862",
+      "minimum_percent_eligible_relays=60",
+      "number_consensus_relays=6436",
+      "number_eligible_relays=6000",
+      "percent_eligible_relays=93",
+      "software=sbws",
+      "software_version=1.0.3",
+      "=====",
+      "bw=38000 bw_mean=1127824 bw_median=1180062 desc_avg_bw=1073741824 "
+          + "desc_obs_bw_last=17230879 desc_obs_bw_mean=14732306 error_circ=0 "
+          + "error_misc=0 error_stream=1 "
+          + "master_key_ed25519=YaqV4vbvPYKucElk297eVdNArDz9HtIwUoIeo0+cVIpQ "
+          + "nick=Test node_id=$68A483E05A2ABDCA6DA5A3EF8DB5177638A27F80 "
+          + "rtt=380 success=1 time=2018-05-08T16:13:26",
+      "bw=1 bw_mean=199162 bw_median=185675 desc_avg_bw=409600 "
+          + "desc_obs_bw_last=836165 desc_obs_bw_mean=858030 error_circ=0 "
+          + "error_misc=0 error_stream=0 "
+          + "master_key_ed25519=a6a+dZadrQBtfSbmQkP7j2ardCmLnm5NJ4ZzkvDxbo0I "
+          + "nick=Test2 node_id=$96C15995F30895689291F455587BD94CA427B6FC "
+          + "rtt=378 success=1 time=2018-05-08T16:13:36" };
+
+  @Test
+  public void testSpecExample120() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample120).build(), null);
+    assertEquals(LocalDateTime.of(2018, 4, 16, 20, 49, 18),
+        bandwidthFile.timestamp());
+    assertEquals("1.2.0", bandwidthFile.version());
+    assertEquals("sbws", bandwidthFile.software());
+    assertEquals(Optional.of("1.0.3"), bandwidthFile.softwareVersion());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 21, 49, 18)),
+        bandwidthFile.fileCreated());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 25)),
+        bandwidthFile.generatorStarted());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 26)),
+        bandwidthFile.earliestBandwidth());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 20, 49, 18)),
+        bandwidthFile.latestBandwidth());
+    assertEquals(Optional.of(6000), bandwidthFile.numberEligibleRelays());
+    assertEquals(Optional.of(60), bandwidthFile.minimumPercentEligibleRelays());
+    assertEquals(Optional.of(6436), bandwidthFile.numberConsensusRelays());
+    assertEquals(Optional.of(93), bandwidthFile.percentEligibleRelays());
+    assertEquals(Optional.of(3862),
+        bandwidthFile.minimumNumberEligibleRelays());
+    assertFalse(bandwidthFile.scannerCountry().isPresent());
+    assertFalse(bandwidthFile.destinationsCountries().isPresent());
+    assertFalse(bandwidthFile.recentConsensusCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityListCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityRelayCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementAttemptCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementFailureCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedErrorCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedNearCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedOldCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedFewCount().isPresent());
+    assertFalse(bandwidthFile.timeToReportHalfNetwork().isPresent());
+  }
+
+  @Test
+  public void testNumberEligibleRelaysNotAnInt()
+      throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Unable to parse int"));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample120)
+        .replaceLineStartingWith("number_eligible_relays=6000",
+        "number_eligible_relays=sixthousand").build(), null);
+  }
+
+  /**
+   * Example from bandwidth-file-spec.txt: Version 1.2.0, generated by sbws
+   * version 1.0.3 when there are not enough eligible measured relays.
+   */
+  private static final String[] specExample120NotEnough = new String[] {
+      "1540496079",
+      "version=1.2.0",
+      "earliest_bandwidth=2018-10-20T19:35:52",
+      "file_created=2018-10-25T19:35:03",
+      "generator_started=2018-10-25T11:42:56",
+      "latest_bandwidth=2018-10-25T19:34:39",
+      "minimum_number_eligible_relays=3862",
+      "minimum_percent_eligible_relays=60",
+      "number_consensus_relays=6436",
+      "number_eligible_relays=2960",
+      "percent_eligible_relays=46",
+      "software=sbws",
+      "software_version=1.0.3",
+      "=====" };
+
+  @Test
+  public void testSpecExample120NotEnough() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample120NotEnough).build(), null);
+    assertEquals(LocalDateTime.of(2018, 10, 25, 19, 34, 39),
+        bandwidthFile.timestamp());
+    assertEquals("1.2.0", bandwidthFile.version());
+    assertEquals("sbws", bandwidthFile.software());
+    assertEquals(Optional.of("1.0.3"), bandwidthFile.softwareVersion());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 10, 25, 19, 35, 3)),
+        bandwidthFile.fileCreated());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 10, 25, 11, 42, 56)),
+        bandwidthFile.generatorStarted());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 10, 20, 19, 35, 52)),
+        bandwidthFile.earliestBandwidth());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 10, 25, 19, 34, 39)),
+        bandwidthFile.latestBandwidth());
+    assertEquals(Optional.of(2960), bandwidthFile.numberEligibleRelays());
+    assertEquals(Optional.of(60), bandwidthFile.minimumPercentEligibleRelays());
+    assertEquals(Optional.of(6436), bandwidthFile.numberConsensusRelays());
+    assertEquals(Optional.of(46), bandwidthFile.percentEligibleRelays());
+    assertEquals(Optional.of(3862),
+        bandwidthFile.minimumNumberEligibleRelays());
+    assertFalse(bandwidthFile.scannerCountry().isPresent());
+    assertFalse(bandwidthFile.destinationsCountries().isPresent());
+    assertFalse(bandwidthFile.recentConsensusCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityListCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityRelayCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementAttemptCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementFailureCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedErrorCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedNearCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedOldCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedFewCount().isPresent());
+    assertFalse(bandwidthFile.timeToReportHalfNetwork().isPresent());
+  }
+
+  /**
+   * Example from bandwidth-file-spec.txt: Version 1.3.0 headers generated by
+   * sbws version 1.0.4.
+   */
+  private static final String[] specExample130Headers = new String[] {
+      "1523911758",
+      "version=1.3.0",
+      "latest_bandwidth=2018-04-16T20:49:18",
+      "destinations_countries=TH,ZZ",
+      "file_created=2018-04-16T21:49:18",
+      "generator_started=2018-04-16T15:13:25",
+      "earliest_bandwidth=2018-04-16T15:13:26",
+      "minimum_number_eligible_relays=3862",
+      "minimum_percent_eligible_relays=60",
+      "number_consensus_relays=6436",
+      "number_eligible_relays=6000",
+      "percent_eligible_relays=93",
+      "scanner_country=SN",
+      "software=sbws",
+      "software_version=1.0.4",
+      "=====" };
+
+  @Test
+  public void testSpecExample130Headers() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample130Headers).build(), null);
+    assertEquals(LocalDateTime.of(2018, 4, 16, 20, 49, 18),
+        bandwidthFile.timestamp());
+    assertEquals("1.3.0", bandwidthFile.version());
+    assertEquals("sbws", bandwidthFile.software());
+    assertEquals(Optional.of("1.0.4"), bandwidthFile.softwareVersion());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 21, 49, 18)),
+        bandwidthFile.fileCreated());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 25)),
+        bandwidthFile.generatorStarted());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 26)),
+        bandwidthFile.earliestBandwidth());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 20, 49, 18)),
+        bandwidthFile.latestBandwidth());
+    assertEquals(Optional.of(6000), bandwidthFile.numberEligibleRelays());
+    assertEquals(Optional.of(60), bandwidthFile.minimumPercentEligibleRelays());
+    assertEquals(Optional.of(6436), bandwidthFile.numberConsensusRelays());
+    assertEquals(Optional.of(93), bandwidthFile.percentEligibleRelays());
+    assertEquals(Optional.of(3862),
+        bandwidthFile.minimumNumberEligibleRelays());
+    assertEquals(Optional.of("SN"), bandwidthFile.scannerCountry());
+    assertArrayEquals(new String[] { "TH", "ZZ" },
+        bandwidthFile.destinationsCountries().orElse(null));
+    assertFalse(bandwidthFile.recentConsensusCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityListCount().isPresent());
+    assertFalse(bandwidthFile.recentPriorityRelayCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementAttemptCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementFailureCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedErrorCount().isPresent());
+    assertFalse(
+        bandwidthFile.recentMeasurementsExcludedNearCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedOldCount().isPresent());
+    assertFalse(bandwidthFile.recentMeasurementsExcludedFewCount().isPresent());
+    assertFalse(bandwidthFile.timeToReportHalfNetwork().isPresent());
+  }
+
+  @Test
+  public void testScannerCountryLowerCase() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Invalid country code 'sn'."));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample130Headers)
+        .replaceLineStartingWith("scanner_country", "scanner_country=sn")
+        .build(), null);
+  }
+
+  @Test
+  public void testDestinationsCountriesLowerCase()
+      throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Invalid country code list 'th,zz'."));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample130Headers)
+        .replaceLineStartingWith("destinations_countries",
+            "destinations_countries=th,zz")
+        .build(), null);
+  }
+
+  @Test
+  public void testDestinationsCountriesEndingWithComma()
+      throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Invalid country code list 'TH,'."));
+    new BandwidthFileImpl(new TestDescriptorBuilder(specExample130Headers)
+        .replaceLineStartingWith("destinations_countries",
+            "destinations_countries=TH,")
+        .build(), null);
+  }
+
+  /**
+   * Example from bandwidth-file-spec.txt: Version 1.4.0 generated by sbws
+   * version 1.1.0.
+   */
+  private static final String[] specExample140 = new String[] {
+      "1523911758",
+      "version=1.4.0",
+      "latest_bandwidth=2018-04-16T20:49:18",
+      "destinations_countries=TH,ZZ",
+      "file_created=2018-04-16T21:49:18",
+      "generator_started=2018-04-16T15:13:25",
+      "earliest_bandwidth=2018-04-16T15:13:26",
+      "minimum_number_eligible_relays=3862",
+      "minimum_percent_eligible_relays=60",
+      "number_consensus_relays=6436",
+      "number_eligible_relays=6000",
+      "percent_eligible_relays=93",
+      "recent_measurement_attempt_count=6243",
+      "recent_measurement_failure_count=732",
+      "recent_measurements_excluded_error_count=969",
+      "recent_measurements_excluded_few_count=3946",
+      "recent_measurements_excluded_near_count=90",
+      "recent_measurements_excluded_old_count=0",
+      "recent_priority_list_count=20",
+      "recent_priority_relay_count=6243",
+      "scanner_country=SN",
+      "software=sbws",
+      "software_version=1.1.0",
+      "time_to_report_half_network=57273",
+      "=====",
+      "bw=1 error_circ=1 error_destination=0 error_misc=0 error_second_relay=0 "
+        + "error_stream=0 "
+        + "master_key_ed25519=J3HQ24kOQWac3L1xlFLp7gY91qkb5NuKxjj1BhDi+m8 "
+        + "nick=snap269 node_id=$DC4D609F95A52614D1E69C752168AF1FCAE0B05F "
+        + "relay_recent_measurement_attempt_count=3 "
+        + "relay_recent_measurements_excluded_error_count=1 "
+        + "relay_recent_measurements_excluded_near_count=3 "
+        + "relay_recent_consensus_count=3 relay_recent_priority_list_count=3 "
+        + "success=3 time=2019-03-16T18:20:57 unmeasured=1 vote=0",
+      "bw=1 error_circ=0 error_destination=0 error_misc=0 error_second_relay=0 "
+        + "error_stream=2 "
+        + "master_key_ed25519=h6ZB1E1yBFWIMloUm9IWwjgaPXEpL5cUbuoQDgdSDKg "
+        + "nick=relay node_id=$C4544F9E209A9A9B99591D548B3E2822236C0503 "
+        + "relay_recent_measurement_attempt_count=3 "
+        + "relay_recent_measurements_excluded_error_count=2 "
+        + "relay_recent_measurements_excluded_few_count=1 "
+        + "relay_recent_consensus_count=3 relay_recent_priority_list_count=3 "
+        + "success=1 time=2019-03-17T06:50:58 unmeasured=1 vote=0" };
+
+  @Test
+  public void testSpecExample140() throws DescriptorParseException {
+    BandwidthFile bandwidthFile = new BandwidthFileImpl(
+        new TestDescriptorBuilder(specExample140).build(), null);
+    assertEquals(LocalDateTime.of(2018, 4, 16, 20, 49, 18),
+        bandwidthFile.timestamp());
+    assertEquals("1.4.0", bandwidthFile.version());
+    assertEquals("sbws", bandwidthFile.software());
+    assertEquals(Optional.of("1.1.0"), bandwidthFile.softwareVersion());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 21, 49, 18)),
+        bandwidthFile.fileCreated());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 25)),
+        bandwidthFile.generatorStarted());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 15, 13, 26)),
+        bandwidthFile.earliestBandwidth());
+    assertEquals(Optional.of(LocalDateTime.of(2018, 4, 16, 20, 49, 18)),
+        bandwidthFile.latestBandwidth());
+    assertEquals(Optional.of(6000), bandwidthFile.numberEligibleRelays());
+    assertEquals(Optional.of(60), bandwidthFile.minimumPercentEligibleRelays());
+    assertEquals(Optional.of(6436), bandwidthFile.numberConsensusRelays());
+    assertEquals(Optional.of(93), bandwidthFile.percentEligibleRelays());
+    assertEquals(Optional.of(3862),
+        bandwidthFile.minimumNumberEligibleRelays());
+    assertEquals(Optional.of("SN"), bandwidthFile.scannerCountry());
+    assertArrayEquals(new String[] { "TH", "ZZ" },
+        bandwidthFile.destinationsCountries().orElse(null));
+    assertFalse(bandwidthFile.recentConsensusCount().isPresent());
+    assertEquals(Optional.of(20),
+        bandwidthFile.recentPriorityListCount());
+    assertEquals(Optional.of(6243),
+        bandwidthFile.recentPriorityRelayCount());
+    assertEquals(Optional.of(6243),
+        bandwidthFile.recentMeasurementAttemptCount());
+    assertEquals(Optional.of(732),
+        bandwidthFile.recentMeasurementFailureCount());
+    assertEquals(Optional.of(969),
+        bandwidthFile.recentMeasurementsExcludedErrorCount());
+    assertEquals(Optional.of(90),
+        bandwidthFile.recentMeasurementsExcludedNearCount());
+    assertEquals(Optional.of(0),
+        bandwidthFile.recentMeasurementsExcludedOldCount());
+    assertEquals(Optional.of(3946),
+        bandwidthFile.recentMeasurementsExcludedFewCount());
+    assertEquals(Optional.of(Duration.ofSeconds(57273L)),
+        bandwidthFile.timeToReportHalfNetwork());
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/impl/TestDescriptorBuilder.java b/src/test/java/org/torproject/descriptor/impl/TestDescriptorBuilder.java
new file mode 100644
index 0000000..a596c9d
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/TestDescriptorBuilder.java
@@ -0,0 +1,120 @@
+/* Copyright 2016--2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+/**
+ * Builds a test descriptor by concatenating the given lines with newlines and
+ * writing the output to a byte array.
+ */
+class TestDescriptorBuilder extends ArrayList<String> {
+
+  /**
+   * Initializes a new test descriptor builder with the given lines.
+   */
+  TestDescriptorBuilder(String ... lines) {
+    this.addAll(Arrays.asList(lines));
+  }
+
+  /**
+   * Appends the given line or lines.
+   */
+  TestDescriptorBuilder appendLines(String ... lines) {
+    this.addAll(Arrays.asList(lines));
+    return this;
+  }
+
+  /**
+   * Removes the given line, or fails if that line cannot be found.
+   */
+  TestDescriptorBuilder removeLine(String line) {
+    if (!this.remove(line)) {
+      fail("Line not contained: " + line);
+    }
+    return this;
+  }
+
+  /**
+   * Removes all but the given line, or fails if that line cannot be found.
+   */
+  TestDescriptorBuilder removeAllExcept(String line) {
+    assertTrue("Line not contained: " + line, this.contains(line));
+    this.retainAll(Arrays.asList(line));
+    return this;
+  }
+
+  /**
+   * Finds the first line that starts with the given line start and inserts the
+   * given lines before it, or fails if no line can be found with that line
+   * start.
+   */
+  TestDescriptorBuilder insertBeforeLineStartingWith(String lineStart,
+      String ... linesToInsert) {
+    for (int i = 0; i < this.size(); i++) {
+      if (this.get(i).startsWith(lineStart)) {
+        this.addAll(i, Arrays.asList(linesToInsert));
+        return this;
+      }
+    }
+    fail("Line start not found: " + lineStart);
+    return this;
+  }
+
+  /**
+   * Finds the first line that starts with the given line start and replaces
+   * that line and possibly subsequent lines, or fails if no line can be found
+   * with that line start or there are not enough lines left to replace.
+   */
+  TestDescriptorBuilder replaceLineStartingWith(String lineStart,
+      String ... linesToReplace) {
+    for (int i = 0; i < this.size(); i++) {
+      if (this.get(i).startsWith(lineStart)) {
+        for (int j = 0; j < linesToReplace.length; j++) {
+          assertTrue("Not enough lines left to replace.",
+              this.size() > i + j);
+          this.set(i + j, linesToReplace[j]);
+        }
+        return this;
+      }
+    }
+    fail("Line start not found: " + lineStart);
+    return this;
+  }
+
+  /**
+   * Finds the first line that starts with the given line start and truncates
+   * that line and possibly subsequent lines, or fails if no line can be found
+   * with that line start.
+   */
+  TestDescriptorBuilder truncateAtLineStartingWith(String lineStart) {
+    for (int i = 0; i < this.size(); i++) {
+      if (this.get(i).startsWith(lineStart)) {
+        while (this.size() > i) {
+          this.remove(i);
+        }
+        return this;
+      }
+    }
+    fail("Line start not found: " + lineStart);
+    return this;
+  }
+
+  /**
+   * Concatenates all descriptor lines with newlines and returns the raw
+   * descriptor bytes as byte array.
+   */
+  byte[] build() {
+    StringBuilder sb = new StringBuilder();
+    for (String line : this) {
+      sb.append(line).append('\n');
+    }
+    return sb.toString().getBytes();
+  }
+}
+

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